From fa68328058373c296053dea7bc5d57f7a3ce4564 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Mon, 21 Jul 2025 14:15:45 +0200 Subject: [PATCH 001/267] search class --- activity_browser/bwutils/search/__init__.py | 1 + .../bwutils/search/searchengine.py | 627 ++++++++++++++++++ 2 files changed, 628 insertions(+) create mode 100644 activity_browser/bwutils/search/__init__.py create mode 100644 activity_browser/bwutils/search/searchengine.py diff --git a/activity_browser/bwutils/search/__init__.py b/activity_browser/bwutils/search/__init__.py new file mode 100644 index 000000000..f9fde759c --- /dev/null +++ b/activity_browser/bwutils/search/__init__.py @@ -0,0 +1 @@ +from searchengine import SearchEngine \ No newline at end of file diff --git a/activity_browser/bwutils/search/searchengine.py b/activity_browser/bwutils/search/searchengine.py new file mode 100644 index 000000000..84a9c6333 --- /dev/null +++ b/activity_browser/bwutils/search/searchengine.py @@ -0,0 +1,627 @@ +from itertools import permutations, chain +import itertools +import functools +from collections import Counter, OrderedDict, Iterable +import pandas as pd +import numpy as np +import re + + +class SearchEngine: + """ + A Search Engine class, takes a dataframe and makes it searchable. + + A search requires a string, and will return a list of unique identifiers in the dataframe. + There are three options for search: + SearchEngine.literal_search(): searches for exact matches of the search query + SearchEngine.fuzzy_search(): searches for approximate matches of search query, sorted by relevance + SearchEngine.search(): combines both of the above, literal matches are returned first, next all fuzzy results, buth subsets sorted by relevance + It is recommended to always use searchEngine.search(), but the other options are there. + + Initialization takes: + df: Dataframe that needs to be searchable. + identifier_name: values in this column will be returned as search results, all values in this column need to be unique. + searchable_columns: these columns need to be searchable, if none are given, all columns will be made searchable. + + Updating data is possible as well: + add_identifier(): adds this identifier to the searchable data + remove_identifier(): removes this identifier from the searchable data + change_identifier(): changes this identifier (wrapper for remove_identifier and add_identifier) + + """ + + def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: list = []): + + # compile regex patterns for cleaning + self.SUB_PATTERN = re.compile(r"[,\(\)\[\]'\"]") # for replacing with empty string + self.SPACE_PATTERN = re.compile(r"[-−:;]") # for replacing with space + self.ONE_SPACE_PATTERN = re.compile(r"\s+") # for replacing multiple white space with 1 space + + self.q = 2 # character lenght of q grams + self.base_weight = 10 # base weigthing for sorting results + + assert identifier_name in df.columns # make sure identifier col exist + assert df[identifier_name].nunique() == df.shape[0] # make sure identifiers are all unique + self.identifier_name = identifier_name + + # ensure columns given actually exist + # always ensure "identifier" is present + if searchable_columns == []: + # if no list is given, assume all columns are searchable + self.columns = list(df.columns) + else: + # create subset of columns to be searchable, discard rest + self.columns = [col for col in searchable_columns if col in df.columns] + if self.identifier_name not in self.columns: # keep identifier col + self.columns.append(self.identifier_name) + df = df[self.columns] + # set the identifier column as index + df = df.set_index(self.identifier_name, drop=False) + + # convert all data to str + df = df.astype(str) + + # find the self.identifier_name column index and store as int + self.identifier_column = self.columns.index(self.identifier_name) + + # store all searchable columns except the identifier + self.regular_columns = [i for i in range(len(self.columns)) if i != self.identifier_column] + + # initialize search index dicts and update df + self.identifier_to_word = {} + self.word_to_identifier = {} + self.word_to_q_grams = {} + self.q_gram_to_word = {} + self.df = pd.DataFrame() + + self.update_index(df) + + # +++ Utility functions + + def update_index(self, update_df: pd.DataFrame) -> None: + """Update search index dicts and the df.""" + + def update_dict(update_me: dict, new: dict) -> dict: + """Update a dict of counters with new dict of counters.""" + for dict_key, _counter in new.items(): + if dict_key in update_me: + update_me[dict_key].update(_counter) + else: + update_me[dict_key] = _counter + return update_me + + # identifier to word and df + i2w, update_df = self.words_in_df(update_df) + self.identifier_to_word = update_dict(self.identifier_to_word, i2w) + self.df = pd.concat([self.df, update_df]) + + # word to identifier + w2i = self.reverse_dict_many_to_one(i2w) + self.word_to_identifier = update_dict(self.word_to_identifier, w2i) + + # word to qgram + w2q = self.list_to_q_grams(w2i.keys()) + self.word_to_q_grams = update_dict(self.word_to_q_grams, w2q) + + # gram to word + q2w = self.reverse_dict_many_to_one(w2q) + self.q_gram_to_word = update_dict(self.q_gram_to_word, q2w) + + def clean_text(self, text: str): + """Clean a string so it doesn't contain weird characters or multiple spaces etc.""" + text = self.SUB_PATTERN.sub("", text.lower()) + text = self.SPACE_PATTERN.sub(" ", text) + text = self.ONE_SPACE_PATTERN.sub(" ", text).strip() + return text + + def text_to_positional_q_gram(self, text: str) -> list: + """Return a positional list of qgrams for the given string. + + https://en.wikipedia.org/wiki/N-gram + q-grams are n-grams on character level. + + qgrams of "word" would be "wo", "or" and "rd" for q=2 + + Note: these are technically positional q grams, but we don't use their + positions currently + """ + q = self.q + + # just return a single-item list if the text is equal or shorter than q + # else, generate qgrams + if len(text) <= q: + return [text] + else: + return [text[i:i + q] for i in range(len(text) - q + 1)] + + def words_in_df(self, df: pd.DataFrame = None) -> tuple[dict, pd.DataFrame]: + """Return a dict of {identifier: word} for df.""" + + df = df if any(df) else self.df + return_df = df.copy() + + df = df.iloc[:, self.regular_columns] + identifier_word_dict = {} + col = [] + + for row in df.itertuples(index=True): + line = self.clean_text(" ".join(row[1:])) + col.append(line) + identifier_word_dict[row[0]] = Counter(line.split(" ")) + + return_df["query_col"] = col + + return identifier_word_dict, return_df + + def reverse_dict_many_to_one(self, dictionary: dict) -> dict: + """Reverse a dictionary of Counter objects.""" + reverse = {} + for identifier, counter_object in dictionary.items(): + for countable, count in counter_object.items(): + if countable not in reverse: + reverse[countable] = Counter() + reverse[countable][identifier] += count + return reverse + + def list_to_q_grams(self, word_list: Iterable) -> dict: + """Convert a list of unique words to a dict with Counter objects. + + + q_gram_dict = { + "word": Counter( + "wo": 1 + "or": 1 + "rd": 1 + ) + } + + """ + q_gram_dict = {} + + for word in word_list: + q_gram_dict[word] = Counter(self.text_to_positional_q_gram(word)) + + return q_gram_dict + + # +++ Changes to searchable data + + def add_identifier(self, identifier, data: dict) -> None: + """Add this identifier to the search index. + + identifier is expected to be a unique identifier that has not been used before + data is expected to be a dict of column names and data + """ + + # make sure we don't add an identifier that already exists + assert identifier not in self.df.index.to_list() + + df_cols = self.columns + + # drop fields that are not in self.df + drop = [col for col in data if col not in df_cols] + for field in drop: + del data[field] + + # add empty field for missing data + for col in df_cols: + if col not in data: + data[col] = "" + + # convert to df + new_df = pd.DataFrame(data, index=[identifier]) + new_df = new_df.astype(str) + + # update the search index data + self.update_index(new_df) + + def remove_identifier(self, identifier) -> None: + """Remove this identifier from self.df and the search index. + """ + + # remove from df + self.df.drop(identifier, inplace=True) + + # find words that may need to be removed + words = self.identifier_to_word[identifier] + for word in words: + if len(self.word_to_identifier[word]) == 1: + # this word is only found in this identifier, + # remove the word and check for q grams + del self.word_to_identifier[word] + + q_grams = self.word_to_q_grams[word] + for q_gram in q_grams: + if len(self.q_gram_to_word[q_gram]) == 1: + # this q_gram is only used in this word, + # remove it + del self.q_gram_to_word[q_gram] + + del self.word_to_q_grams[word] + else: + # remove the identifier from the + del self.word_to_identifier[word][identifier] + # finally, remove the identifier + del self.identifier_to_word[identifier] + + def change_identifier(self, identifier, data: dict) -> None: + """Change this identifier. + + identifier is expected to be a unique identifier that is in use + data is expected to be a dict of column names and data that change + + only changed data needs to be supplied + """ + assert identifier in self.df.index.to_list() + + # get existing data + update_data = dict(self.df.loc[identifier].values) + + # overwrite new data where relevant + for field, value in data.items(): + update_data[field] = value + + # remove the entry + self.remove_identifier(identifier) + + # add entry with new data + self.add_identifier(identifier, update_data) + + # +++ Search + + def filter_dataframe(self, df: pd.DataFrame, pattern: str, search_columns: list = None) -> pd.Series: + """Filter the search columns of a dataframe on a pattern. + + Returns a mask (true/false) pd.Series with matching items.""" + + search_columns = search_columns if search_columns else self.columns + + mask = functools.reduce( + np.logical_or, + [ + df[col].apply(lambda x: pattern in x.lower()) + for col in search_columns + ], + ) + return mask + + def literal_search(self, text): + """Do literal search of the text in all original columns that were given.""" + + identifiers = self.filter_dataframe(self.df, text) + df = self.df.loc[identifiers] + identifiers = df.index.to_list() + + return identifiers + + def osa_distance(self, word1: str, word2: str, cutoff: int = 0, cutoff_return: int = 1000) -> int: + """Calculate the Optimal String Alignment (OSA) edit distance between two strings, return edit distance. + + Has additional cutoff variable, if cutoff is higher than 0 and if the words have + a larger difference in length, immediately return a large number + + OSA is a restricted form of the Damerau–Levenshtein distance. + https://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance#Optimal_string_alignment_distance + + The edit distance is how many operations (insert, delete, substitute or transpose a character) need to happen to convert one string to another. + insert and delete are obvious operations, but substitute and transpose are explained: + substitute: replace one character with another: e.g. word1=cat word2=cab, t->b substitution is 1 operation + transpose: swap the places of two adjacent characters with each other: e.g. word1=coal word2=cola al -> la transposition is 1 operation + + The minimum amount of operations (OSA edit distance) is returned. + """ + + if word1 == word2: + # if the strings are the same, immediately return 0 + return 0 + + len1, len2 = len(word1), len(word2) + + if 0 < cutoff < abs(len1 - len2): + # if the length difference between 2 words is over the cutoff, + # just return instead of calculating the edit distance + return cutoff_return + + if len1 == 0 or len2 == 0: + # in case (at least) one of the strings is empty, + # return the lenth of the longest string + return max(len1, len2) + + # Initialize matrix + distance = [[0] * len2 for _ in range(len1)] + + # calculate shortest edit distance + for i in range(len1): + for j in range(len2): + cost = 0 if word1[i] == word2[j] else 1 + + # Compute distances for insertion, deletion and substitution + insertion = distance[i][j - 1] + 1 if j > 0 else i + 1 + deletion = distance[i - 1][j] + 1 if i > 0 else j + 1 + substitution = distance[i - 1][j - 1] + cost if i > 0 and j > 0 else max(i, j) + cost + + distance[i][j] = min(deletion, insertion, substitution) + + # Compute transposition when relevant + if i > 0 and j > 0 and word1[i] == word2[j - 1] and word1[i - 1] == word2[j]: + transposition = distance[i - 2][j - 2] + 1 if i > 1 and j > 1 else max(i, j) - 1 + distance[i][j] = min(distance[i][j], transposition) + + return distance[len1 - 1][len2 - 1] + + def find_q_gram_matches(self, q_grams: list) -> pd.DataFrame: + """Find which of the given q_grams exist in self.q_gram_to_word, + return a sorted dataframe of best matching words. + """ + n_q_grams = len(q_grams) + + matches = {} + + # find words that match our qgrams + for q_gram in q_grams: + if words := self.q_gram_to_word.get(q_gram, False): + # q_gram exists in our search index + for word in words: + matches[word] = matches.get(word, 0) + words[word] + + # if we find no results, return an empty dataframe + if len(matches) == 0: + return pd.DataFrame({"word": [], "matches": []}) + + # otherwise, create a dataframe and + # reduce search results to most relevant results + matches = {"word": matches.keys(), "matches": matches.values()} + matches = pd.DataFrame(matches) + max_q = max(matches["matches"]) + + # determine how many results we want to keep based on how good our results are + min_q = max(max_q * 0.5, # have at least half of qgrams of best match or... + max(n_q_grams * 0.5, # if more, at least half the qgrams in the query word? + 1)) # okay just do 1 qgram if there are no more in the word + + matches = matches[matches["matches"] >= min_q] + matches = matches.sort_values(by="matches", ascending=False) + matches = matches.reset_index(drop=True) + + return matches.iloc[:min(len(matches), 2500), :] # return at most this many results + + def spell_check(self, text: str) -> OrderedDict: + """Create an OrderedDict of each word in the text (space separated) + with as values possible alternatives. + + Alternatives are first found with q-grams, then refined with string edit distance + + We rank alternative words based on 1) edit distance 2) how often a word is used in an entry + If too many results are found, we only keep edit distance 1, + if we want more results, we keep with longer edit distance up to ... + + + word_results = OrderedDict( + "word": [word, work] + ) + + """ + + word_results = OrderedDict() + + matches_goal = 3 # ideally we have at least this many alternatives + + always_accept_this = 1 # values of this or lower always accepted + never_accept_this = 4 # values this or over always rejected + + # make list of unique words + words = OrderedDict() + for word in text.split(" "): + words[word] = False + words = words.keys() + + words = [self.clean_text(word) for word in words] + + for word in words: + + # first, find possible matches quickly + q_grams = self.text_to_positional_q_gram(word) + possible_matches = self.find_q_gram_matches(q_grams) + + matches = [] + other_matches = {} + + # now, refine with levenshtein + for row in possible_matches.itertuples(): + + edit_distance = self.osa_distance(word, row[1], cutoff=never_accept_this) + + if edit_distance == 0: + continue # we are looking for alternatives only, not the exact word + elif edit_distance <= always_accept_this: + matches.append(row[1]) + elif edit_distance < never_accept_this: + if other_matches.get(edit_distance): + other_matches[edit_distance].append(row[1]) + else: + other_matches[edit_distance] = [row[1]] + + # if we have fewer matches than goal, add more 'less good' matches + if len(matches) < matches_goal: + for i in range(always_accept_this + 1, never_accept_this): + # iteratively increate worse matches + if new := other_matches.get(i): + matches = matches + new + + if len(matches) >= matches_goal: + break + + word_results[word] = matches + + return word_results + + def build_queries(self, query_text) -> list: + """Make all possible subsets of words in the query, including alternative words.""" + query_text = self.spell_check(query_text) + + # find all combinations of the query words as given + queries = list(query_text.keys()) + subsets = list(chain.from_iterable( + (itertools.combinations( + queries, r) for r in range(1, len(queries) + 1)))) + all_queries = [] + + for combination in subsets: + # add the 'default' option + all_queries.append(combination) + # now add all options with all alternatives + for i, word in enumerate(combination): + for alternative in query_text.get(word, []): + alternative_combination = list(combination) + alternative_combination[i] = alternative + all_queries.append(alternative_combination) + + return all_queries + + def weigh_identifiers(self, identifiers: Iterable, weight: int, weighted_ids: Counter) -> Counter: + """Add weights to identifier counter for these identifiers""" + + for identifier in identifiers: + weighted_ids[identifier] += int(weight) + + return weighted_ids + + def search_size_1(self, queries: list, original_words: list, orig_word_weight=11, exact_word_weight=1) -> dict: + """Return a dict of {query_word: Counter(identifier)}. + + queries: is a list of len 1 tuple/lists of words that are a searched word or a 'spell checked' similar word + original words: a list of words actually searched for (not including spellechecked) + + orig_word_weight: additional weight to add to original words + exact_word_weight: additional weight to add to exact word matches (as opposed to be 'in' str) + + First, we find all matching words, creating a dict of words in 'queries' as keys and words matching that query word as list of values + Next, we convert this to identifiers and add weights: + Weight will be increased if matching 'orig_word_weight' or 'exact_word_weight' + """ + matches = {} + # add each word in search index if query_word in word + for word in self.word_to_identifier.keys(): + for query in queries: + # query is list/tuple of len 1 + query_word = query[0] # only use the word + if query_word in word: + words = matches.get(query_word, []) + words.extend([word]) + matches[query_word] = words + + # now convert matched words to matched identifiers + matched_identifiers = {} + for word, matching_words in matches.items(): + for matched_word in matching_words: + weight = self.base_weight + + id_counter = matched_identifiers.get(word, Counter()) + + # add the word n times, where n is the weight, original search word is weighted higher than alternatives + if matched_word in original_words: + weight += orig_word_weight # increase weight for original word + if matched_word == word: + weight += exact_word_weight # increase weight for exact matching word + + id_counter = self.weigh_identifiers(self.word_to_identifier[matched_word], weight, id_counter) + matched_identifiers[word] = id_counter + + return matched_identifiers + + def fuzzy_search(self, text: str) -> list: + """Search the dataframe, finding approximate matches and return a list of identifiers, + ranked by how well each identifier matches the search text. + """ + + queries = self.build_queries(text) + + # make list of unique original words + orig_words = OrderedDict() + for word in text.split(" "): + orig_words[word] = False + orig_words = orig_words.keys() + orig_words = [self.clean_text(word) for word in orig_words] + + # order the queries by the amount of words they contain + # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space + queries_by_size = OrderedDict() + longest_query = max([len(q) for q in queries]) + for query_len in range(1, longest_query + 1): + queries_by_size[query_len] = [q for q in queries if len(q) == query_len] + + # first handle queries of length 1 + query_to_identifier = self.search_size_1(queries_by_size[1], orig_words) + + # get all results into a df, we rank further later + all_identifiers = set() + for id_list in [id_list for id_list in query_to_identifier.values()]: + all_identifiers.update(id_list) + search_df = self.df.loc[list(all_identifiers)] + + # now, we search for combinations of query words and get only those identifiers + # we then reduce de search_df further for only those matching identifiers + # we then search the permutations of that set of words + for q_len, query_set in queries_by_size.items(): + if q_len == 1: + # we already did these above + continue + for query in query_set: + + # get the intersection of all identifiers + # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query + # this ensures we only ever search data where ALL items occur to reduce search-space + query_identifiers = set.intersection(*[set(query_to_identifier.get(q_word)) for q_word in query if + query_to_identifier.get(q_word, False)]) + if len(query_identifiers) == 0: + # there is no match for this combination of query words, skip + break + + # we now add these identifiers to a counter for this query name, + query_name = " ".join(query) + + weight = self.base_weight * q_len + query_to_identifier[query_name] = self.weigh_identifiers(query_identifiers, weight, Counter()) + + # now search for all permutations of this query combined with a space + query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] + for query_perm in permutations(query): + mask = self.filter_dataframe(query_df, " ".join(query_perm), search_columns=["query_col"]) + new_df = query_df.loc[mask].reset_index(drop=True) + if len(new_df) == 0: + # there is no match for this permutation of words, skip + continue + new_ids = list(new_df[self.identifier_name]) + # we weigh a combination of words and next to each other even higher than just the words separately + weight = self.base_weight * q_len * q_len + query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, + query_to_identifier[query_name]) + + # now finally, move to one object sorted list by highest score + all_identifiers = Counter() + for identifiers in query_to_identifier.values(): + all_identifiers += identifiers + + # now sort on highest weights and make list type + sorted_identifiers = [identifier[0] for identifier in all_identifiers.most_common()] + return sorted_identifiers + + def search(self, text) -> list: + """Search the dataframe on this text, return a sorted list of identifiers""" + + literal_identifiers = self.literal_search(text) + fuzzy_identifiers = self.fuzzy_search(text) + + ordered_literal_identifiers = [] + other_identifiers = [] + + # add all fuzzy identifiers to one of two lists, depending on whether they were found in literal search or not + # this guarantees we put the literal matches on top, but still sort within this group based on fuzzy scores + for identifier in fuzzy_identifiers: + if identifier in literal_identifiers: + ordered_literal_identifiers.append(identifier) + else: + other_identifiers.append(identifier) + + identifiers = ordered_literal_identifiers + other_identifiers + + return identifiers From 40bf40747f3e3d1b8422f5526f8662f902acf2a8 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Tue, 22 Jul 2025 10:59:33 +0200 Subject: [PATCH 002/267] Include correct init file as well --- activity_browser/bwutils/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/activity_browser/bwutils/__init__.py b/activity_browser/bwutils/__init__.py index bac9adccd..4bb75c292 100644 --- a/activity_browser/bwutils/__init__.py +++ b/activity_browser/bwutils/__init__.py @@ -12,6 +12,7 @@ from .montecarlo import MonteCarloLCA from .multilca import MLCA, Contributions from .pedigree import PedigreeMatrix +from .search import SearchEngine from .sensitivity_analysis import GlobalSensitivityAnalysis from .superstructure import SuperstructureContributions, SuperstructureMLCA from .uncertainty import (CFUncertaintyInterface, ExchangeUncertaintyInterface, From 413ac1ae1bcc958e70628367c64debdfa6904be4 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Wed, 23 Jul 2025 15:51:06 +0200 Subject: [PATCH 003/267] Search tests, minor corrections, better documentation --- activity_browser/bwutils/search/__init__.py | 2 +- .../bwutils/search/searchengine.py | 168 ++++++++++------ tests/test_search.py | 181 ++++++++++++++++++ 3 files changed, 289 insertions(+), 62 deletions(-) create mode 100644 tests/test_search.py diff --git a/activity_browser/bwutils/search/__init__.py b/activity_browser/bwutils/search/__init__.py index f9fde759c..042045a6b 100644 --- a/activity_browser/bwutils/search/__init__.py +++ b/activity_browser/bwutils/search/__init__.py @@ -1 +1 @@ -from searchengine import SearchEngine \ No newline at end of file +from .searchengine import SearchEngine \ No newline at end of file diff --git a/activity_browser/bwutils/search/searchengine.py b/activity_browser/bwutils/search/searchengine.py index 84a9c6333..a444e3d19 100644 --- a/activity_browser/bwutils/search/searchengine.py +++ b/activity_browser/bwutils/search/searchengine.py @@ -1,7 +1,8 @@ from itertools import permutations, chain import itertools import functools -from collections import Counter, OrderedDict, Iterable +from collections import Counter, OrderedDict +from typing import Iterable import pandas as pd import numpy as np import re @@ -15,7 +16,8 @@ class SearchEngine: There are three options for search: SearchEngine.literal_search(): searches for exact matches of the search query SearchEngine.fuzzy_search(): searches for approximate matches of search query, sorted by relevance - SearchEngine.search(): combines both of the above, literal matches are returned first, next all fuzzy results, buth subsets sorted by relevance + SearchEngine.search(): combines both of the above, literal matches are returned first, next all fuzzy results, + but subsets sorted by relevance. It is recommended to always use searchEngine.search(), but the other options are there. Initialization takes: @@ -37,11 +39,15 @@ def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: l self.SPACE_PATTERN = re.compile(r"[-−:;]") # for replacing with space self.ONE_SPACE_PATTERN = re.compile(r"\s+") # for replacing multiple white space with 1 space - self.q = 2 # character lenght of q grams - self.base_weight = 10 # base weigthing for sorting results + self.q = 2 # character length of q grams + self.base_weight = 10 # base weighting for sorting results + + if identifier_name not in df.columns: # make sure identifier col exist + raise NameError(f"Identifier column {identifier_name} not found in dataframe. Use an existing column name.") + if df[identifier_name].nunique() != df.shape[0]: # make sure identifiers are all unique + raise KeyError( + f"Identifier column {identifier_name} must only contain unique values. Found {df[identifier_name].nunique()} unique values for length {df.shape[0]}") - assert identifier_name in df.columns # make sure identifier col exist - assert df[identifier_name].nunique() == df.shape[0] # make sure identifiers are all unique self.identifier_name = identifier_name # ensure columns given actually exist @@ -99,11 +105,11 @@ def update_dict(update_me: dict, new: dict) -> dict: w2i = self.reverse_dict_many_to_one(i2w) self.word_to_identifier = update_dict(self.word_to_identifier, w2i) - # word to qgram + # word to q-gram w2q = self.list_to_q_grams(w2i.keys()) self.word_to_q_grams = update_dict(self.word_to_q_grams, w2q) - # gram to word + # q-gram to word q2w = self.reverse_dict_many_to_one(w2q) self.q_gram_to_word = update_dict(self.q_gram_to_word, q2w) @@ -115,20 +121,18 @@ def clean_text(self, text: str): return text def text_to_positional_q_gram(self, text: str) -> list: - """Return a positional list of qgrams for the given string. + """Return a positional list of q-grams for the given string. - https://en.wikipedia.org/wiki/N-gram q-grams are n-grams on character level. + q-grams at q=2 of "word" would be "wo", "or" and "rd" + https://en.wikipedia.org/wiki/N-gram - qgrams of "word" would be "wo", "or" and "rd" for q=2 - - Note: these are technically positional q grams, but we don't use their - positions currently + Note: these are technically _positional_ q-grams, but we don't use their positions currently. """ q = self.q # just return a single-item list if the text is equal or shorter than q - # else, generate qgrams + # else, generate q-grams if len(text) <= q: return [text] else: @@ -145,10 +149,9 @@ def words_in_df(self, df: pd.DataFrame = None) -> tuple[dict, pd.DataFrame]: col = [] for row in df.itertuples(index=True): - line = self.clean_text(" ".join(row[1:])) + line = self.clean_text(" | ".join(row[1:])) col.append(line) identifier_word_dict[row[0]] = Counter(line.split(" ")) - return_df["query_col"] = col return identifier_word_dict, return_df @@ -166,6 +169,7 @@ def reverse_dict_many_to_one(self, dictionary: dict) -> dict: def list_to_q_grams(self, word_list: Iterable) -> dict: """Convert a list of unique words to a dict with Counter objects. + Number will be the occurrences of that q-gram in that word. q_gram_dict = { "word": Counter( @@ -191,9 +195,13 @@ def add_identifier(self, identifier, data: dict) -> None: identifier is expected to be a unique identifier that has not been used before data is expected to be a dict of column names and data """ - - # make sure we don't add an identifier that already exists - assert identifier not in self.df.index.to_list() + # make sure we the identifier does not yet exist + if identifier in self.df.index.to_list(): + raise Exception( + f"Identifier '{identifier}' is already in use, use a different identifier or use the change_identifier function.") + if data[self.identifier_name] != identifier: + raise Exception( + f"Identifier argument '{identifier}' and data in identifier column '{data[self.identifier_name]}' must be the same.") df_cols = self.columns @@ -217,6 +225,10 @@ def add_identifier(self, identifier, data: dict) -> None: def remove_identifier(self, identifier) -> None: """Remove this identifier from self.df and the search index. """ + # make sure the identifier exists + if identifier not in self.df.index.to_list(): + raise Exception( + f"Identifier '{identifier}' does not exist in the search data, cannot remove identifier that do not exist.") # remove from df self.df.drop(identifier, inplace=True) @@ -238,7 +250,7 @@ def remove_identifier(self, identifier) -> None: del self.word_to_q_grams[word] else: - # remove the identifier from the + # remove the identifier from the dict del self.word_to_identifier[word][identifier] # finally, remove the identifier del self.identifier_to_word[identifier] @@ -251,10 +263,17 @@ def change_identifier(self, identifier, data: dict) -> None: only changed data needs to be supplied """ - assert identifier in self.df.index.to_list() + # make sure the identifier exists + if identifier not in self.df.index.to_list(): + raise Exception( + f"Identifier '{identifier}' does not exist in the search data, use an existing identifier or use the add_identifier function.") + if data[self.identifier_name] != identifier: + raise Exception( + f"Identifier argument '{identifier}' and data in identifier column '{data[self.identifier_name]}' must be the same.") # get existing data - update_data = dict(self.df.loc[identifier].values) + update_data = {col: self.df.loc[identifier, col] for col in self.df.columns} + del update_data["query_col"] # overwrite new data where relevant for field, value in data.items(): @@ -297,35 +316,39 @@ def osa_distance(self, word1: str, word2: str, cutoff: int = 0, cutoff_return: i """Calculate the Optimal String Alignment (OSA) edit distance between two strings, return edit distance. Has additional cutoff variable, if cutoff is higher than 0 and if the words have - a larger difference in length, immediately return a large number + a larger edit distance, return a large number (note: cutoff <= edit_dist, not cutoff < edit_dist) OSA is a restricted form of the Damerau–Levenshtein distance. https://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance#Optimal_string_alignment_distance The edit distance is how many operations (insert, delete, substitute or transpose a character) need to happen to convert one string to another. insert and delete are obvious operations, but substitute and transpose are explained: - substitute: replace one character with another: e.g. word1=cat word2=cab, t->b substitution is 1 operation - transpose: swap the places of two adjacent characters with each other: e.g. word1=coal word2=cola al -> la transposition is 1 operation + substitute: replace one character with another: e.g. word1='cat' word2='cab', 't'->'b' substitution is 1 operation + transpose: swap the places of two adjacent characters with each other: e.g. word1='coal' word2='cola' 'al' -> 'la' transposition is 1 operation - The minimum amount of operations (OSA edit distance) is returned. + The minimum amount of edit operations (OSA edit distance) is returned. """ - if word1 == word2: # if the strings are the same, immediately return 0 return 0 len1, len2 = len(word1), len(word2) - if 0 < cutoff < abs(len1 - len2): + if 0 < cutoff <= abs(len1 - len2): # if the length difference between 2 words is over the cutoff, # just return instead of calculating the edit distance return cutoff_return if len1 == 0 or len2 == 0: # in case (at least) one of the strings is empty, - # return the lenth of the longest string + # return the length of the longest string return max(len1, len2) + if len1 < len2 and cutoff > 0: + # make sure word1 is always the longest (required for early stopping with cutoff) + word1, word2 = word2, word1 + len1, len2 = len2, len1 + # Initialize matrix distance = [[0] * len2 for _ in range(len1)] @@ -346,9 +369,12 @@ def osa_distance(self, word1: str, word2: str, cutoff: int = 0, cutoff_return: i transposition = distance[i - 2][j - 2] + 1 if i > 1 and j > 1 else max(i, j) - 1 distance[i][j] = min(distance[i][j], transposition) - return distance[len1 - 1][len2 - 1] + # stop early if we surpass cutoff + if 0 < cutoff <= distance[i][j]: + return cutoff_return + return distance[i][j] - def find_q_gram_matches(self, q_grams: list) -> pd.DataFrame: + def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: """Find which of the given q_grams exist in self.q_gram_to_word, return a sorted dataframe of best matching words. """ @@ -356,7 +382,7 @@ def find_q_gram_matches(self, q_grams: list) -> pd.DataFrame: matches = {} - # find words that match our qgrams + # find words that match our q-grams for q_gram in q_grams: if words := self.q_gram_to_word.get(q_gram, False): # q_gram exists in our search index @@ -371,12 +397,12 @@ def find_q_gram_matches(self, q_grams: list) -> pd.DataFrame: # reduce search results to most relevant results matches = {"word": matches.keys(), "matches": matches.values()} matches = pd.DataFrame(matches) - max_q = max(matches["matches"]) + max_q = max(matches["matches"]) # this has the most matching q-grams # determine how many results we want to keep based on how good our results are - min_q = max(max_q * 0.5, # have at least half of qgrams of best match or... - max(n_q_grams * 0.5, # if more, at least half the qgrams in the query word? - 1)) # okay just do 1 qgram if there are no more in the word + min_q = max(max_q * 0.32, # have at least a third of q-grams of best match or... + max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? + 1)) # okay just do 1 q-gram if there are no more in the word matches = matches[matches["matches"] >= min_q] matches = matches.sort_values(by="matches", ascending=False) @@ -400,11 +426,9 @@ def spell_check(self, text: str) -> OrderedDict: ) """ - word_results = OrderedDict() matches_goal = 3 # ideally we have at least this many alternatives - always_accept_this = 1 # values of this or lower always accepted never_accept_this = 4 # values this or over always rejected @@ -420,12 +444,12 @@ def spell_check(self, text: str) -> OrderedDict: # first, find possible matches quickly q_grams = self.text_to_positional_q_gram(word) - possible_matches = self.find_q_gram_matches(q_grams) + possible_matches = self.find_q_gram_matches(set(q_grams)) matches = [] other_matches = {} - # now, refine with levenshtein + # now, refine with edit distance for row in possible_matches.itertuples(): edit_distance = self.osa_distance(word, row[1], cutoff=never_accept_this) @@ -443,13 +467,11 @@ def spell_check(self, text: str) -> OrderedDict: # if we have fewer matches than goal, add more 'less good' matches if len(matches) < matches_goal: for i in range(always_accept_this + 1, never_accept_this): - # iteratively increate worse matches + # iteratively increase 'worse' matches so we hit goal of minimum alternatives if new := other_matches.get(i): matches = matches + new - if len(matches) >= matches_goal: break - word_results[word] = matches return word_results @@ -477,19 +499,17 @@ def build_queries(self, query_text) -> list: return all_queries - def weigh_identifiers(self, identifiers: Iterable, weight: int, weighted_ids: Counter) -> Counter: - """Add weights to identifier counter for these identifiers""" - - for identifier in identifiers: - weighted_ids[identifier] += int(weight) - + def weigh_identifiers(self, identifiers: Counter, weight: int, weighted_ids: Counter) -> Counter: + """Add weights to identifier counter for these identifiers times how often it occurs in identifier.""" + for identifier, occurrences in identifiers.items(): + weighted_ids[identifier] += (weight * occurrences) return weighted_ids - def search_size_1(self, queries: list, original_words: list, orig_word_weight=11, exact_word_weight=1) -> dict: + def search_size_1(self, queries: list, original_words: list, orig_word_weight=5, exact_word_weight=1) -> dict: """Return a dict of {query_word: Counter(identifier)}. queries: is a list of len 1 tuple/lists of words that are a searched word or a 'spell checked' similar word - original words: a list of words actually searched for (not including spellechecked) + original words: a list of words actually searched for (not including spellchecked) orig_word_weight: additional weight to add to original words exact_word_weight: additional weight to add to exact word matches (as opposed to be 'in' str) @@ -514,7 +534,6 @@ def search_size_1(self, queries: list, original_words: list, orig_word_weight=11 for word, matching_words in matches.items(): for matched_word in matching_words: weight = self.base_weight - id_counter = matched_identifiers.get(word, Counter()) # add the word n times, where n is the weight, original search word is weighted higher than alternatives @@ -531,6 +550,19 @@ def search_size_1(self, queries: list, original_words: list, orig_word_weight=11 def fuzzy_search(self, text: str) -> list: """Search the dataframe, finding approximate matches and return a list of identifiers, ranked by how well each identifier matches the search text. + + 1. First, identifiers matching single words (and spell-checked alternatives) are found and weighted. + 2. If the search term consisted of multiple words, combinations of those words are checked next. + 2.1 Increasing in size (first two words, then three etc.), we look for identifiers that contain that set of + words, these are also weighted, based on the sum of all one-word weights (from first step) and the length + of the sequence. + 2.2 Next, we also look specifically for combinations occurring next to each other. And add more weight like + the step above (2.1). + We multiply the weighting of step 2 by the sequence length, based on the assumption that finding more search + words will be a more relevant result than just finding a single word, and again if they are in the + correct order. + + Finally, all found identifiers are sorted on their weight and returned. """ queries = self.build_queries(text) @@ -569,13 +601,24 @@ def fuzzy_search(self, text: str) -> list: # get the intersection of all identifiers # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query - # this ensures we only ever search data where ALL items occur to reduce search-space - query_identifiers = set.intersection(*[set(query_to_identifier.get(q_word)) for q_word in query if - query_to_identifier.get(q_word, False)]) - if len(query_identifiers) == 0: + # this ensures we only ever search data where ALL items occur to substantially reduce search-space + # finally, make this a Counter (with each item=1) so we can properly weigh things later + query_identifier_set = set.intersection(*[set(query_to_identifier.get(q_word)) for q_word in query if + query_to_identifier.get(q_word, False)]) + if len(query_identifier_set) == 0: # there is no match for this combination of query words, skip break + # now we convert the query identifiers to a Counter of 'occurrence', + # where we weigh queries with only original words higher + query_identifiers = Counter() + for identifier in query_identifier_set: + weight = 0 + for query_word in query: + weight += query_to_identifier[query_word][identifier] + + query_identifiers[identifier] = weight + # we now add these identifiers to a counter for this query name, query_name = " ".join(query) @@ -590,12 +633,15 @@ def fuzzy_search(self, text: str) -> list: if len(new_df) == 0: # there is no match for this permutation of words, skip continue - new_ids = list(new_df[self.identifier_name]) - # we weigh a combination of words and next to each other even higher than just the words separately - weight = self.base_weight * q_len * q_len + new_id_list = new_df[self.identifier_name] + + new_ids = Counter() + for new_id in new_id_list: + new_ids[new_id] = query_identifiers[new_id] + + # we weigh a combination of words that is next also to each other even higher than just the words separately query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, query_to_identifier[query_name]) - # now finally, move to one object sorted list by highest score all_identifiers = Counter() for identifiers in query_to_identifier.values(): diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 000000000..231859d28 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,181 @@ +import pytest +import pandas as pd +from activity_browser.bwutils.search import SearchEngine + + +def data_for_test(): + return pd.DataFrame([ + ["a", "coal production", "coal"], + ["b", "coal production", "something"], + ["c", "coal production", "coat"], + ["d", "coal hello production", "something"], + ["e", "dont find me", "hello world"], + ["f", "coat", "another word"], + ["g", "coalispartofthisword", "things"], + ["h", "coal", "coal"], + ], + columns = ["id", "col1", "col2"]) + + +def test_search_init(): + """Do initialization tests.""" + df = data_for_test() + + # init search class with non-existent identifier col and fail + with pytest.raises(Exception): + _ = SearchEngine(df, identifier_name="non_existent_col_name") + # init search class with non-unique identifiers and fail + df2 = df.copy() + df2.iloc[0, 0] = "b" + with pytest.raises(Exception): + _ = SearchEngine(df2, identifier_name="id") + # init search class correctly + se = SearchEngine(df, identifier_name="id") + + +def test_search_base(): + """Do checks for search ranking.""" + + df = data_for_test() + + # init search class and two searches + se = SearchEngine(df, identifier_name="id") + # do search on specific term + assert se.search("coal") == ["a", "h", "c", "b", "d", "g", "f"] + # do search on other term + assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] + + # init search class with 1 col searchable + se = SearchEngine(df, identifier_name="id", searchable_columns=["col2"]) + assert se.search("coal") == ["a", "h", "c"] + + +def test_search_add_identifier(): + """Do tests for adding identifier.""" + df = data_for_test() + + # create base item to add + new_base_item = { + "id": "i", + "col1": "coal production", + "col2": "coal production" + } + + # use mismatched identifier and fail + se = SearchEngine(df, identifier_name="id") + with pytest.raises(Exception): + se.add_identifier(identifier="j", data=new_base_item) + + # use existing identifier and fail + se = SearchEngine(df, identifier_name="id") + wrong_id = new_base_item.copy() + wrong_id["id"] = "a" + with pytest.raises(Exception): + se.add_identifier(identifier="a", data=wrong_id) + + # use column too many (should be removed) + se = SearchEngine(df, identifier_name="id") + col_more = new_base_item.copy() + col_more["col3"] = "word" + se.add_identifier(identifier="i", data=col_more) + assert "col3" not in se.df.columns + + # use column less (should be filled with empty string) + se = SearchEngine(df, identifier_name="id") + col_less = new_base_item.copy() + del col_less["col2"] + se.add_identifier(identifier="i", data=col_less) + assert se.df.loc["i", "col2"] == "" + + # do search, add item and verify results are different + se = SearchEngine(df, identifier_name="id") + assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] + se.add_identifier(identifier="i", data=new_base_item) + assert se.search("coal production") == ["i", "a", "c", "b", "d", "h", "f", "g"] + + +def test_search_remove_identifier(): + """Do tests for removing identifier.""" + df = data_for_test() + + # use non-existent identifier and fail + se = SearchEngine(df, identifier_name="id") + with pytest.raises(Exception): + se.remove_identifier(identifier="i") + + # do search, remove item and verify results are different + se = SearchEngine(df, identifier_name="id") + assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] + se.remove_identifier(identifier="a") + assert se.search("coal production") == ["c", "b", "d", "h", "f", "g"] + + +def test_search_change_identifier(): + """Do tests for changing identifier.""" + df = data_for_test() + + # create base item to add + edit_data = { + "id": "a", + "col1": "cant find me anymore", + "col2": "something different" + } + + # use non-existent identifier and fail + se = SearchEngine(df, identifier_name="id") + missing_id = edit_data.copy() + missing_id["id"] = "i" + with pytest.raises(Exception): + se.change_identifier(identifier="i", data=missing_id) + + # use mismatched identifier and fail + se = SearchEngine(df, identifier_name="id") + wrong_id = edit_data.copy() + wrong_id["id"] = "i" + with pytest.raises(Exception): + se.change_identifier(identifier="a", data=wrong_id) + + # do search, change item and verify results are different + se = SearchEngine(df, identifier_name="id") + assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] + se.change_identifier(identifier="a", data=edit_data) + assert se.search("coal production") == ["c", "b", "d", "h", "f", "g"] + # now change the same item partially and verify results are different + new_edit_data = { + "id": "a", + "col1": "coal" + } + se.change_identifier(identifier="a", data=new_edit_data) + assert se.search("coal production") == ["c", "b", "d", "h", "a", "f", "g"] + + +def test_string_distance(): + """Do tests specifically for string distance function""" + df = data_for_test() + se = SearchEngine(df, identifier_name="id") + + # same word + assert se.osa_distance("coal", "coal") == 0 + # empty string is length of other word + assert se.osa_distance("coal", "") == 4 + + # insert + assert se.osa_distance("coal", "coa") == 1 + # delete + assert se.osa_distance("coal", "coall") == 1 + # substitute + assert se.osa_distance("coal", "coat") == 1 + # transpose + assert se.osa_distance("coal", "cola") == 1 + + # longer edit distance + assert se.osa_distance("coal", "chocolate") == 6 + # reverse order gives same result + assert se.osa_distance("coal", "chocolate") == se.osa_distance("chocolate", "coal") + # cutoff + assert se.osa_distance("coal", "chocolate", cutoff=5, cutoff_return=1000) == 1000 + assert se.osa_distance("coal", "chocolate", cutoff=6, cutoff_return=1000) == 1000 + assert se.osa_distance("coal", "chocolate", cutoff=7, cutoff_return=1000) == 6 + # length cutoff + assert se.osa_distance("coal", "coallongword") == 8 + assert se.osa_distance("coal", "coallongword", cutoff=5, cutoff_return=1000) == 1000 From d01387fa2d0b9b8d4ad5fde805399a8bdfebea72 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Thu, 24 Jul 2025 12:47:39 +0200 Subject: [PATCH 004/267] Improve search speed with many results. --- .../bwutils/search/searchengine.py | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/activity_browser/bwutils/search/searchengine.py b/activity_browser/bwutils/search/searchengine.py index a444e3d19..d0a722b22 100644 --- a/activity_browser/bwutils/search/searchengine.py +++ b/activity_browser/bwutils/search/searchengine.py @@ -2,7 +2,7 @@ import itertools import functools from collections import Counter, OrderedDict -from typing import Iterable +from typing import Iterable, Optional import pandas as pd import numpy as np import re @@ -287,13 +287,12 @@ def change_identifier(self, identifier, data: dict) -> None: # +++ Search - def filter_dataframe(self, df: pd.DataFrame, pattern: str, search_columns: list = None) -> pd.Series: + def filter_dataframe(self, df: pd.DataFrame, pattern: str, search_columns: Optional[list] = None) -> pd.Series: """Filter the search columns of a dataframe on a pattern. Returns a mask (true/false) pd.Series with matching items.""" search_columns = search_columns if search_columns else self.columns - mask = functools.reduce( np.logical_or, [ @@ -303,13 +302,15 @@ def filter_dataframe(self, df: pd.DataFrame, pattern: str, search_columns: list ) return mask - def literal_search(self, text): + def literal_search(self, text, df: Optional[pd.DataFrame] = None) -> list: """Do literal search of the text in all original columns that were given.""" - identifiers = self.filter_dataframe(self.df, text) - df = self.df.loc[identifiers] - identifiers = df.index.to_list() + if df is None: + df = self.df.copy() + identifiers = self.filter_dataframe(df, text) + df = df.loc[identifiers] + identifiers = df.index.to_list() return identifiers def osa_distance(self, word1: str, word2: str, cutoff: int = 0, cutoff_return: int = 1000) -> int: @@ -652,22 +653,20 @@ def fuzzy_search(self, text: str) -> list: return sorted_identifiers def search(self, text) -> list: - """Search the dataframe on this text, return a sorted list of identifiers""" - - literal_identifiers = self.literal_search(text) + """Search the dataframe on this text, return a sorted list of identifiers.""" fuzzy_identifiers = self.fuzzy_search(text) - - ordered_literal_identifiers = [] - other_identifiers = [] - - # add all fuzzy identifiers to one of two lists, depending on whether they were found in literal search or not - # this guarantees we put the literal matches on top, but still sort within this group based on fuzzy scores - for identifier in fuzzy_identifiers: - if identifier in literal_identifiers: - ordered_literal_identifiers.append(identifier) - else: - other_identifiers.append(identifier) - - identifiers = ordered_literal_identifiers + other_identifiers + if len(fuzzy_identifiers) == 0: + return [] + + # take the fuzzy search sub-set of data and search it literally + df = self.df.loc[fuzzy_identifiers].copy() + literal_identifiers = self.literal_search(text, df) + if len(literal_identifiers) == 0: + return fuzzy_identifiers + + # append any fuzzy identifiers that were not found in the literal search + fuzzy_identifiers = [ + _id for _id in fuzzy_identifiers if _id not in set(literal_identifiers)] + identifiers = literal_identifiers + fuzzy_identifiers return identifiers From 295995e4cbebdcc548185b64648364c2d03f16d6 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Thu, 24 Jul 2025 16:09:05 +0200 Subject: [PATCH 005/267] Add basic logging to SearchEngine --- .../bwutils/search/searchengine.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/activity_browser/bwutils/search/searchengine.py b/activity_browser/bwutils/search/searchengine.py index d0a722b22..8b097cc84 100644 --- a/activity_browser/bwutils/search/searchengine.py +++ b/activity_browser/bwutils/search/searchengine.py @@ -2,12 +2,17 @@ import itertools import functools from collections import Counter, OrderedDict +from logging import getLogger +from time import time from typing import Iterable, Optional import pandas as pd import numpy as np import re +log = getLogger(__name__) + + class SearchEngine: """ A Search Engine class, takes a dataframe and makes it searchable. @@ -33,7 +38,7 @@ class SearchEngine: """ def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: list = []): - + t = time() # compile regex patterns for cleaning self.SUB_PATTERN = re.compile(r"[,\(\)\[\]'\"]") # for replacing with empty string self.SPACE_PATTERN = re.compile(r"[-−:;]") # for replacing with space @@ -70,7 +75,7 @@ def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: l # find the self.identifier_name column index and store as int self.identifier_column = self.columns.index(self.identifier_name) - # store all searchable columns except the identifier + # store all searchable column indices except the identifier self.regular_columns = [i for i in range(len(self.columns)) if i != self.identifier_column] # initialize search index dicts and update df @@ -81,6 +86,7 @@ def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: l self.df = pd.DataFrame() self.update_index(df) + log.debug(f"Search engine initialized in {time() - t:.2f} seconds for {len(self.df)} items") # +++ Utility functions @@ -652,16 +658,21 @@ def fuzzy_search(self, text: str) -> list: sorted_identifiers = [identifier[0] for identifier in all_identifiers.most_common()] return sorted_identifiers - def search(self, text) -> list: + def search(self, text, col_modifiers: Optional[dict] = None) -> list: """Search the dataframe on this text, return a sorted list of identifiers.""" + t = time() fuzzy_identifiers = self.fuzzy_search(text) if len(fuzzy_identifiers) == 0: + log.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") return [] # take the fuzzy search sub-set of data and search it literally df = self.df.loc[fuzzy_identifiers].copy() + literal_identifiers = self.literal_search(text, df) if len(literal_identifiers) == 0: + log.debug( + f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") return fuzzy_identifiers # append any fuzzy identifiers that were not found in the literal search @@ -669,4 +680,6 @@ def search(self, text) -> list: _id for _id in fuzzy_identifiers if _id not in set(literal_identifiers)] identifiers = literal_identifiers + fuzzy_identifiers + log.debug( + f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") return identifiers From 79754ca7659e41ba69f2ce103b0e6ee8662821a1 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Thu, 24 Jul 2025 16:24:34 +0200 Subject: [PATCH 006/267] . --- activity_browser/bwutils/search/searchengine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activity_browser/bwutils/search/searchengine.py b/activity_browser/bwutils/search/searchengine.py index 8b097cc84..86e56f29e 100644 --- a/activity_browser/bwutils/search/searchengine.py +++ b/activity_browser/bwutils/search/searchengine.py @@ -658,7 +658,7 @@ def fuzzy_search(self, text: str) -> list: sorted_identifiers = [identifier[0] for identifier in all_identifiers.most_common()] return sorted_identifiers - def search(self, text, col_modifiers: Optional[dict] = None) -> list: + def search(self, text) -> list: """Search the dataframe on this text, return a sorted list of identifiers.""" t = time() fuzzy_identifiers = self.fuzzy_search(text) From 6bd39f71af2367d1fc1bfea115012d75bee95402 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Mon, 28 Jul 2025 13:48:07 +0200 Subject: [PATCH 007/267] Base implementation of metadata specific class --- activity_browser/bwutils/__init__.py | 2 +- activity_browser/bwutils/metadata.py | 14 +- activity_browser/bwutils/search/__init__.py | 2 +- .../bwutils/search/searchengine.py | 185 +++++++++++++++++- 4 files changed, 197 insertions(+), 6 deletions(-) diff --git a/activity_browser/bwutils/__init__.py b/activity_browser/bwutils/__init__.py index 4bb75c292..12b565e61 100644 --- a/activity_browser/bwutils/__init__.py +++ b/activity_browser/bwutils/__init__.py @@ -12,7 +12,7 @@ from .montecarlo import MonteCarloLCA from .multilca import MLCA, Contributions from .pedigree import PedigreeMatrix -from .search import SearchEngine +from .search import SearchEngine, MetaDataSearchEngine from .sensitivity_analysis import GlobalSensitivityAnalysis from .superstructure import SuperstructureContributions, SuperstructureMLCA from .uncertainty import (CFUncertaintyInterface, ExchangeUncertaintyInterface, diff --git a/activity_browser/bwutils/metadata.py b/activity_browser/bwutils/metadata.py index 9790e7885..dc7e7328a 100644 --- a/activity_browser/bwutils/metadata.py +++ b/activity_browser/bwutils/metadata.py @@ -7,8 +7,6 @@ from typing import Set from logging import getLogger -from playhouse.shortcuts import model_to_dict - import pandas as pd from qtpy.QtCore import Qt, QObject, Signal, SignalInstance @@ -17,6 +15,8 @@ from bw2data.errors import UnknownObject from bw2data.backends import sqlite3_lci_db, ActivityDataset +from activity_browser.bwutils.search import MetaDataSearchEngine + from activity_browser import signals @@ -190,6 +190,7 @@ def sync(self) -> None: con.close() self.dataframe = self._parse_df(node_df) + self.init_search() # init search index self.synced.emit() @@ -333,5 +334,14 @@ def _unpacker(self, classifications: list, system: str) -> list: system_classifications.append(result) # result is either "" or the classification return system_classifications + def init_search(self): + allowed_cols = [ + "id", "name", "synonyms", "unit", "key", "database", # generic + "CAS number", "categories", # biosphere specific + "product", "reference product", "classifications", "location", "properties" # activity specific + ] + + MetaDataSearchEngine(self.dataframe, identifier_name="id", searchable_columns=allowed_cols) + AB_metadata = MetaDataStore() diff --git a/activity_browser/bwutils/search/__init__.py b/activity_browser/bwutils/search/__init__.py index 042045a6b..85e30c9be 100644 --- a/activity_browser/bwutils/search/__init__.py +++ b/activity_browser/bwutils/search/__init__.py @@ -1 +1 @@ -from .searchengine import SearchEngine \ No newline at end of file +from .searchengine import SearchEngine, MetaDataSearchEngine \ No newline at end of file diff --git a/activity_browser/bwutils/search/searchengine.py b/activity_browser/bwutils/search/searchengine.py index 86e56f29e..f1a32f4a1 100644 --- a/activity_browser/bwutils/search/searchengine.py +++ b/activity_browser/bwutils/search/searchengine.py @@ -676,9 +676,190 @@ def search(self, text) -> list: return fuzzy_identifiers # append any fuzzy identifiers that were not found in the literal search - fuzzy_identifiers = [ + remaining_fuzzy_identifiers = [ _id for _id in fuzzy_identifiers if _id not in set(literal_identifiers)] - identifiers = literal_identifiers + fuzzy_identifiers + identifiers = literal_identifiers + remaining_fuzzy_identifiers + + log.debug( + f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return identifiers + + +class MetaDataSearchEngine(SearchEngine): + def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: + """Overwritten for extra database specific reduction of results. + """ + n_q_grams = len(q_grams) + + matches = {} + + # find words that match our q-grams + for q_gram in q_grams: + if words := self.q_gram_to_word.get(q_gram, False): + # q_gram exists in our search index + for word in words: + if isinstance(self.database_ids, set): + # DATABASE SPECIFIC now filter on whether word is in the database + in_db = False + for _id in self.word_to_identifier[word]: + if _id in self.database_ids: + in_db = True + break + else: + in_db = True + if in_db: + matches[word] = matches.get(word, 0) + words[word] + + # if we find no results, return an empty dataframe + if len(matches) == 0: + return pd.DataFrame({"word": [], "matches": []}) + + # otherwise, create a dataframe and + # reduce search results to most relevant results + matches = {"word": matches.keys(), "matches": matches.values()} + matches = pd.DataFrame(matches) + max_q = max(matches["matches"]) # this has the most matching q-grams + + # determine how many results we want to keep based on how good our results are + min_q = max(max_q * 0.32, # have at least a third of q-grams of best match or... + max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? + 1)) # okay just do 1 q-gram if there are no more in the word + + matches = matches[matches["matches"] >= min_q] + matches = matches.sort_values(by="matches", ascending=False) + matches = matches.reset_index(drop=True) + + return matches.iloc[:min(len(matches), 2500), :] # return at most this many results + + def fuzzy_search(self, text: str) -> list: + """Overwritten for extra database specific reduction of results. + """ + queries = self.build_queries(text) + + # make list of unique original words + orig_words = OrderedDict() + for word in text.split(" "): + orig_words[word] = False + orig_words = orig_words.keys() + orig_words = [self.clean_text(word) for word in orig_words] + + # order the queries by the amount of words they contain + # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space + queries_by_size = OrderedDict() + longest_query = max([len(q) for q in queries]) + for query_len in range(1, longest_query + 1): + queries_by_size[query_len] = [q for q in queries if len(q) == query_len] + + # first handle queries of length 1 + query_to_identifier = self.search_size_1(queries_by_size[1], orig_words) + + # DATABASE SPECIFIC ensure all identifiers are in the database + if isinstance(self.database_ids, set): + new_q2i = {} + for word, _ids in query_to_identifier.items(): + keep = set.intersection(set(_ids.keys()), self.database_ids) + new_id_counter = Counter() + for _id in keep: + new_id_counter[_id] = _ids[_id] + if len(new_id_counter) > 0: + new_q2i[word] = new_id_counter + query_to_identifier = new_q2i + + # get all results into a df, we rank further later + all_identifiers = set() + for id_list in [id_list for id_list in query_to_identifier.values()]: + all_identifiers.update(id_list) + search_df = self.df.loc[list(all_identifiers)] + + # now, we search for combinations of query words and get only those identifiers + # we then reduce de search_df further for only those matching identifiers + # we then search the permutations of that set of words + for q_len, query_set in queries_by_size.items(): + if q_len == 1: + # we already did these above + continue + for query in query_set: + + # get the intersection of all identifiers + # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query + # this ensures we only ever search data where ALL items occur to substantially reduce search-space + # finally, make this a Counter (with each item=1) so we can properly weigh things later + query_identifier_set = set.intersection(*[set(query_to_identifier.get(q_word)) for q_word in query if + query_to_identifier.get(q_word, False)]) + if len(query_identifier_set) == 0: + # there is no match for this combination of query words, skip + break + + # now we convert the query identifiers to a Counter of 'occurrence', + # where we weigh queries with only original words higher + query_identifiers = Counter() + for identifier in query_identifier_set: + weight = 0 + for query_word in query: + weight += query_to_identifier[query_word][identifier] + + query_identifiers[identifier] = weight + + # we now add these identifiers to a counter for this query name, + query_name = " ".join(query) + + weight = self.base_weight * q_len + query_to_identifier[query_name] = self.weigh_identifiers(query_identifiers, weight, Counter()) + + # now search for all permutations of this query combined with a space + query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] + for query_perm in permutations(query): + mask = self.filter_dataframe(query_df, " ".join(query_perm), search_columns=["query_col"]) + new_df = query_df.loc[mask].reset_index(drop=True) + if len(new_df) == 0: + # there is no match for this permutation of words, skip + continue + new_id_list = new_df[self.identifier_name] + + new_ids = Counter() + for new_id in new_id_list: + new_ids[new_id] = query_identifiers[new_id] + + # we weigh a combination of words that is next also to each other even higher than just the words separately + query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, + query_to_identifier[query_name]) + # now finally, move to one object sorted list by highest score + all_identifiers = Counter() + for identifiers in query_to_identifier.values(): + all_identifiers += identifiers + + # now sort on highest weights and make list type + sorted_identifiers = [identifier[0] for identifier in all_identifiers.most_common()] + return sorted_identifiers + + def search(self, text, database: Optional[str] = None) -> list: + """Search the dataframe on this text, return a sorted list of identifiers.""" + t = time() + + # get the set of ids that is in this database + if database is not None: + self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) + else: + self.database_ids = None + + fuzzy_identifiers = self.fuzzy_search(text) + if len(fuzzy_identifiers) == 0: + log.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return [] + + # take the fuzzy search sub-set of data and search it literally + df = self.df.loc[fuzzy_identifiers].copy() + + literal_identifiers = self.literal_search(text, df) + if len(literal_identifiers) == 0: + log.debug( + f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return fuzzy_identifiers + + # append any fuzzy identifiers that were not found in the literal search + remaining_fuzzy_identifiers = [ + _id for _id in fuzzy_identifiers if _id not in set(literal_identifiers)] + identifiers = literal_identifiers + remaining_fuzzy_identifiers log.debug( f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") From 91a3328bfd732568e43029433a3e4adfff639171 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Mon, 18 Aug 2025 10:04:55 +0200 Subject: [PATCH 008/267] minor changes to searchengine --- .../bwutils/search/searchengine.py | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/activity_browser/bwutils/search/searchengine.py b/activity_browser/bwutils/search/searchengine.py index f1a32f4a1..cbb088afd 100644 --- a/activity_browser/bwutils/search/searchengine.py +++ b/activity_browser/bwutils/search/searchengine.py @@ -195,19 +195,19 @@ def list_to_q_grams(self, word_list: Iterable) -> dict: # +++ Changes to searchable data - def add_identifier(self, identifier, data: dict) -> None: + def add_identifier(self, data: dict, make_searchable=[]) -> None: """Add this identifier to the search index. identifier is expected to be a unique identifier that has not been used before data is expected to be a dict of column names and data """ + #TODO add ability to add new columns with make_searchable + identifier = data[self.identifier_name] + # make sure we the identifier does not yet exist if identifier in self.df.index.to_list(): raise Exception( f"Identifier '{identifier}' is already in use, use a different identifier or use the change_identifier function.") - if data[self.identifier_name] != identifier: - raise Exception( - f"Identifier argument '{identifier}' and data in identifier column '{data[self.identifier_name]}' must be the same.") df_cols = self.columns @@ -273,9 +273,12 @@ def change_identifier(self, identifier, data: dict) -> None: if identifier not in self.df.index.to_list(): raise Exception( f"Identifier '{identifier}' does not exist in the search data, use an existing identifier or use the add_identifier function.") - if data[self.identifier_name] != identifier: + if self.identifier_name in data.keys() and data[self.identifier_name] != identifier: raise Exception( - f"Identifier argument '{identifier}' and data in identifier column '{data[self.identifier_name]}' must be the same.") + "Identifier field cannot be changed, first remove item and then add new identifier") + if "query_col" in data.keys(): + log.debug( + f"Field 'query_col' is a protected field for search engine and will be ignored for changing {identifier}") # get existing data update_data = {col: self.df.loc[identifier, col] for col in self.df.columns} @@ -289,7 +292,7 @@ def change_identifier(self, identifier, data: dict) -> None: self.remove_identifier(identifier) # add entry with new data - self.add_identifier(identifier, update_data) + self.add_identifier(update_data) # +++ Search @@ -661,6 +664,11 @@ def fuzzy_search(self, text: str) -> list: def search(self, text) -> list: """Search the dataframe on this text, return a sorted list of identifiers.""" t = time() + + if len(text) == 0: + log.debug(f"Empty search, returned all items") + return self.df.index.to_list() + fuzzy_identifiers = self.fuzzy_search(text) if len(fuzzy_identifiers) == 0: log.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") @@ -836,6 +844,10 @@ def search(self, text, database: Optional[str] = None) -> list: """Search the dataframe on this text, return a sorted list of identifiers.""" t = time() + if len(text) == 0: + log.debug(f"Empty search, returned all items") + return self.df.index.to_list() + # get the set of ids that is in this database if database is not None: self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) From 8ec0cf401527b4d5e070662a54977fbc53493f16 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Tue, 2 Sep 2025 12:46:40 +0200 Subject: [PATCH 009/267] - Solve bug in OSA distance for early stopping with long similar strings - Add and improve tests --- .../bwutils/search/searchengine.py | 92 ++++++++++---- tests/test_search.py | 117 +++++++++++++----- 2 files changed, 151 insertions(+), 58 deletions(-) diff --git a/activity_browser/bwutils/search/searchengine.py b/activity_browser/bwutils/search/searchengine.py index cbb088afd..913242a3a 100644 --- a/activity_browser/bwutils/search/searchengine.py +++ b/activity_browser/bwutils/search/searchengine.py @@ -39,6 +39,8 @@ class SearchEngine: def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: list = []): t = time() + log.debug(f"SearchEngine initializing for {len(df)} items") + # compile regex patterns for cleaning self.SUB_PATTERN = re.compile(r"[,\(\)\[\]'\"]") # for replacing with empty string self.SPACE_PATTERN = re.compile(r"[-−:;]") # for replacing with space @@ -86,7 +88,8 @@ def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: l self.df = pd.DataFrame() self.update_index(df) - log.debug(f"Search engine initialized in {time() - t:.2f} seconds for {len(self.df)} items") + + log.debug(f"SearchEngine Initialized in {time() - t:.2f} seconds") # +++ Utility functions @@ -102,6 +105,9 @@ def update_dict(update_me: dict, new: dict) -> dict: update_me[dict_key] = _counter return update_me + t = time() + size_old = len(self.df) + # identifier to word and df i2w, update_df = self.words_in_df(update_df) self.identifier_to_word = update_dict(self.identifier_to_word, i2w) @@ -119,6 +125,13 @@ def update_dict(update_me: dict, new: dict) -> dict: q2w = self.reverse_dict_many_to_one(w2q) self.q_gram_to_word = update_dict(self.q_gram_to_word, q2w) + size_new = len(self.df) + size_dif = size_new - size_old + size_msg = (f"{size_dif} changed items at {round(size_dif/(time() - t), 0)} items/sec " + f"({size_new} items currently)") if size_dif > 1 \ + else f"1 changed item ({size_new} items currently)" + log.debug(f"Search index updated in {time() - t:.2f} seconds for {size_msg}.") + def clean_text(self, text: str): """Clean a string so it doesn't contain weird characters or multiple spaces etc.""" text = self.SUB_PATTERN.sub("", text.lower()) @@ -193,6 +206,13 @@ def list_to_q_grams(self, word_list: Iterable) -> dict: return q_gram_dict + def word_in_index(self, word: str) -> bool: + """Convenience function to check if a single word is in the search index.""" + if " " in word: + raise Exception( + f"Given word '{word}' must not contain spaces.") + return word in self.word_to_identifier.keys() + # +++ Changes to searchable data def add_identifier(self, data: dict, make_searchable=[]) -> None: @@ -228,9 +248,12 @@ def add_identifier(self, data: dict, make_searchable=[]) -> None: # update the search index data self.update_index(new_df) - def remove_identifier(self, identifier) -> None: + def remove_identifier(self, identifier, logging=True) -> None: """Remove this identifier from self.df and the search index. """ + if logging: + t = time() + # make sure the identifier exists if identifier not in self.df.index.to_list(): raise Exception( @@ -261,6 +284,10 @@ def remove_identifier(self, identifier) -> None: # finally, remove the identifier del self.identifier_to_word[identifier] + if logging: + log.debug(f"Search index updated in {time() - t:.2f} seconds " + f"for 1 removed item ({len(self.df)} items currently).") + def change_identifier(self, identifier, data: dict) -> None: """Change this identifier. @@ -289,7 +316,7 @@ def change_identifier(self, identifier, data: dict) -> None: update_data[field] = value # remove the entry - self.remove_identifier(identifier) + self.remove_identifier(identifier, logging=False) # add entry with new data self.add_identifier(update_data) @@ -380,7 +407,7 @@ def osa_distance(self, word1: str, word2: str, cutoff: int = 0, cutoff_return: i distance[i][j] = min(distance[i][j], transposition) # stop early if we surpass cutoff - if 0 < cutoff <= distance[i][j]: + if 0 < cutoff <= min(distance[i]): return cutoff_return return distance[i][j] @@ -428,19 +455,24 @@ def spell_check(self, text: str) -> OrderedDict: We rank alternative words based on 1) edit distance 2) how often a word is used in an entry If too many results are found, we only keep edit distance 1, - if we want more results, we keep with longer edit distance up to ... - + if we want more results, we keep with longer edit distance up to `never_accept_this` word_results = OrderedDict( - "word": [word, work] + "word": [work] ) + NOTE: only ALTERNATIVES are ever returned, this function returns empty list for item BOTH when + 1) the exact word is in the data + 2) when there are no suitable alternatives """ + count_occurence = lambda x: sum(self.word_to_identifier[x].values()) # count occurences of a word + word_results = OrderedDict() - matches_goal = 3 # ideally we have at least this many alternatives - always_accept_this = 1 # values of this or lower always accepted - never_accept_this = 4 # values this or over always rejected + matches_min = 3 # ideally we have at least this many alternatives + matches_max = 10 # ideally don't much more than this many matches + always_accept_this = 1 # values of this edit distance or lower always accepted + never_accept_this = 4 # values this edit distance or over always rejected # make list of unique words words = OrderedDict() @@ -451,12 +483,12 @@ def spell_check(self, text: str) -> OrderedDict: words = [self.clean_text(word) for word in words] for word in words: - # first, find possible matches quickly q_grams = self.text_to_positional_q_gram(word) possible_matches = self.find_q_gram_matches(set(q_grams)) matches = [] + first_matches = Counter() other_matches = {} # now, refine with edit distance @@ -467,23 +499,33 @@ def spell_check(self, text: str) -> OrderedDict: if edit_distance == 0: continue # we are looking for alternatives only, not the exact word elif edit_distance <= always_accept_this: - matches.append(row[1]) + first_matches[row[1]] = count_occurence(row[1]) elif edit_distance < never_accept_this: - if other_matches.get(edit_distance): - other_matches[edit_distance].append(row[1]) - else: - other_matches[edit_distance] = [row[1]] + if not other_matches.get(edit_distance): + other_matches[edit_distance] = Counter() + other_matches[edit_distance][row[1]] = count_occurence(row[1]) + else: + continue + # add matches in correct order: + for match, _ in first_matches.most_common(): + matches.append(match) # if we have fewer matches than goal, add more 'less good' matches - if len(matches) < matches_goal: + if len(matches) < matches_min: for i in range(always_accept_this + 1, never_accept_this): - # iteratively increase 'worse' matches so we hit goal of minimum alternatives + # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives if new := other_matches.get(i): - matches = matches + new - if len(matches) >= matches_goal: - break - word_results[word] = matches + prev_num = 10e100 + for match, num in new.most_common(): + if num == prev_num: + matches.append(match) + elif num != prev_num and len(matches <= matches_max): + matches.append(match) + else: + break + prev_num = num + word_results[word] = matches return word_results def build_queries(self, query_text) -> list: @@ -515,7 +557,7 @@ def weigh_identifiers(self, identifiers: Counter, weight: int, weighted_ids: Cou weighted_ids[identifier] += (weight * occurrences) return weighted_ids - def search_size_1(self, queries: list, original_words: list, orig_word_weight=5, exact_word_weight=1) -> dict: + def search_size_1(self, queries: list, original_words: set, orig_word_weight=5, exact_word_weight=1) -> dict: """Return a dict of {query_word: Counter(identifier)}. queries: is a list of len 1 tuple/lists of words that are a searched word or a 'spell checked' similar word @@ -582,7 +624,7 @@ def fuzzy_search(self, text: str) -> list: for word in text.split(" "): orig_words[word] = False orig_words = orig_words.keys() - orig_words = [self.clean_text(word) for word in orig_words] + orig_words = {self.clean_text(word) for word in orig_words} # order the queries by the amount of words they contain # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space @@ -749,7 +791,7 @@ def fuzzy_search(self, text: str) -> list: for word in text.split(" "): orig_words[word] = False orig_words = orig_words.keys() - orig_words = [self.clean_text(word) for word in orig_words] + orig_words = {self.clean_text(word) for word in orig_words} # order the queries by the amount of words they contain # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space diff --git a/tests/test_search.py b/tests/test_search.py index 231859d28..036e6c864 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -17,6 +17,7 @@ def data_for_test(): columns = ["id", "col1", "col2"]) +# test standard init def test_search_init(): """Do initialization tests.""" df = data_for_test() @@ -33,8 +34,90 @@ def test_search_init(): se = SearchEngine(df, identifier_name="id") +# test internals +def test_reverse_dict(): + """Do test to reverse the special Counter dict.""" + df = data_for_test() + se = SearchEngine(df, identifier_name="id") + + # reverse once and verify + w2i = se.reverse_dict_many_to_one(se.identifier_to_word) + assert w2i == se.word_to_identifier + + # reverse same and verify is same as input + i2w = se.reverse_dict_many_to_one(w2i) + assert i2w == se.identifier_to_word + + +def test_string_distance(): + """Do tests specifically for string distance function.""" + df = data_for_test() + se = SearchEngine(df, identifier_name="id") + + # same word + assert se.osa_distance("coal", "coal") == 0 + # empty string is length of other word + assert se.osa_distance("coal", "") == 4 + + # insert + assert se.osa_distance("coal", "coa") == 1 + # delete + assert se.osa_distance("coal", "coall") == 1 + # substitute + assert se.osa_distance("coal", "coat") == 1 + # transpose + assert se.osa_distance("coal", "cola") == 1 + + # longer edit distance + assert se.osa_distance("coal", "chocolate") == 6 + # reverse order gives same result + assert se.osa_distance("coal", "chocolate") == se.osa_distance("chocolate", "coal") + # cutoff + assert se.osa_distance("coal", "chocolate", cutoff=5, cutoff_return=1000) == 1000 + assert se.osa_distance("coal", "chocolate", cutoff=6, cutoff_return=1000) == 1000 + assert se.osa_distance("coal", "chocolate", cutoff=7, cutoff_return=1000) == 6 + # length cutoff + assert se.osa_distance("coal", "coallongword") == 8 + assert se.osa_distance("coal", "coallongword", cutoff=5, cutoff_return=1000) == 1000 + + # two entirely different words (test of early stopping) + assert se.osa_distance("brown", "jumped") == 6 + assert se.osa_distance("brown", "jumped", cutoff=6, cutoff_return=1000) == 1000 + assert se.osa_distance("brown", "jumped", cutoff=7, cutoff_return=1000) == 6 + + +# test functionality +def test_in_index(): + """Do checks for checking if word is in the index.""" + df = data_for_test() + se = SearchEngine(df, identifier_name="id") + + # use string with space + with pytest.raises(Exception): + se.word_in_index("coal and space") + + assert se.word_in_index("coal") + assert not se.word_in_index("coa") + + +def test_spellcheck(): + """Do checks spell checking.""" + df = data_for_test() + se = SearchEngine(df, identifier_name="id") + + checked = se.spell_check("coa productions something flintstones") + # coal HAS to be first, it is found more often in the data + assert checked["coa"] == ["coal", "coat"] + # find production + assert checked["productions"] == ["production"] + # should be empty as there is no alternative (but this word occurs) + assert checked["something"] == [] + # should be empty as there is no alternative (does not exist) + assert checked["flintstones"] == [] + + def test_search_base(): - """Do checks for search ranking.""" + """Do checks for correct search ranking.""" df = data_for_test() @@ -147,35 +230,3 @@ def test_search_change_identifier(): } se.change_identifier(identifier="a", data=new_edit_data) assert se.search("coal production") == ["c", "b", "d", "h", "a", "f", "g"] - - -def test_string_distance(): - """Do tests specifically for string distance function""" - df = data_for_test() - se = SearchEngine(df, identifier_name="id") - - # same word - assert se.osa_distance("coal", "coal") == 0 - # empty string is length of other word - assert se.osa_distance("coal", "") == 4 - - # insert - assert se.osa_distance("coal", "coa") == 1 - # delete - assert se.osa_distance("coal", "coall") == 1 - # substitute - assert se.osa_distance("coal", "coat") == 1 - # transpose - assert se.osa_distance("coal", "cola") == 1 - - # longer edit distance - assert se.osa_distance("coal", "chocolate") == 6 - # reverse order gives same result - assert se.osa_distance("coal", "chocolate") == se.osa_distance("chocolate", "coal") - # cutoff - assert se.osa_distance("coal", "chocolate", cutoff=5, cutoff_return=1000) == 1000 - assert se.osa_distance("coal", "chocolate", cutoff=6, cutoff_return=1000) == 1000 - assert se.osa_distance("coal", "chocolate", cutoff=7, cutoff_return=1000) == 6 - # length cutoff - assert se.osa_distance("coal", "coallongword") == 8 - assert se.osa_distance("coal", "coallongword", cutoff=5, cutoff_return=1000) == 1000 From e2bb1cf6f732b469e3558fb484982ba961cbb874 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Wed, 3 Sep 2025 11:37:31 +0200 Subject: [PATCH 010/267] update add/change identifier (and tests) to accept dataframes instead of dicts --- .../bwutils/search/searchengine.py | 91 +++++++++++-------- tests/test_search.py | 56 ++++++------ 2 files changed, 80 insertions(+), 67 deletions(-) diff --git a/activity_browser/bwutils/search/searchengine.py b/activity_browser/bwutils/search/searchengine.py index 913242a3a..aaf090eb6 100644 --- a/activity_browser/bwutils/search/searchengine.py +++ b/activity_browser/bwutils/search/searchengine.py @@ -78,7 +78,7 @@ def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: l self.identifier_column = self.columns.index(self.identifier_name) # store all searchable column indices except the identifier - self.regular_columns = [i for i in range(len(self.columns)) if i != self.identifier_column] + self.searchable_columns = [i for i in range(len(self.columns)) if i != self.identifier_column] # initialize search index dicts and update df self.identifier_to_word = {} @@ -112,6 +112,7 @@ def update_dict(update_me: dict, new: dict) -> dict: i2w, update_df = self.words_in_df(update_df) self.identifier_to_word = update_dict(self.identifier_to_word, i2w) self.df = pd.concat([self.df, update_df]) + self.df = self.df.fillna("") # ensure we don't add unwanted NA through concatenations # word to identifier w2i = self.reverse_dict_many_to_one(i2w) @@ -163,7 +164,7 @@ def words_in_df(self, df: pd.DataFrame = None) -> tuple[dict, pd.DataFrame]: df = df if any(df) else self.df return_df = df.copy() - df = df.iloc[:, self.regular_columns] + df = df.iloc[:, self.searchable_columns] identifier_word_dict = {} col = [] @@ -215,38 +216,47 @@ def word_in_index(self, word: str) -> bool: # +++ Changes to searchable data - def add_identifier(self, data: dict, make_searchable=[]) -> None: - """Add this identifier to the search index. + def add_identifier(self, data: pd.DataFrame) -> None: + """Add this data to the search index. - identifier is expected to be a unique identifier that has not been used before - data is expected to be a dict of column names and data + identifier column is REQUIRED to be present + ALL data in the given dataframe will be added, if columns should not be added, they should be removed before + calling this function """ - #TODO add ability to add new columns with make_searchable - identifier = data[self.identifier_name] - # make sure we the identifier does not yet exist - if identifier in self.df.index.to_list(): + # ensure we have identifier column + if self.identifier_name not in data.columns: raise Exception( - f"Identifier '{identifier}' is already in use, use a different identifier or use the change_identifier function.") - - df_cols = self.columns + f"Identifier column '{self.identifier_name}' not in new data, impossible to add data without identifier") - # drop fields that are not in self.df - drop = [col for col in data if col not in df_cols] - for field in drop: - del data[field] + # make sure we the identifier does not yet exist + existing_ids = set(self.df.index.to_list()) + for identifier in data[self.identifier_name]: + if identifier in existing_ids: + raise Exception( + f"Identifier '{identifier}' is already in use, use a different identifier or use the change_identifier function.") - # add empty field for missing data + df_cols = self.columns + # add cols to new data that are missing for col in df_cols: - if col not in data: - data[col] = "" - - # convert to df - new_df = pd.DataFrame(data, index=[identifier]) - new_df = new_df.astype(str) + if col not in data.columns: + data[col] = [""] * len(data) + # re-order cols, first existing, then new + new_cols = [col for col in data.columns if col not in self.columns if col not in set(df_cols)] + data_cols = df_cols + new_cols + data = data[data_cols] # re-order new data to be in correct order + + # add cols from new data to correct places + self.columns.extend(new_cols) + self.searchable_columns.extend([i for i, col in enumerate(data_cols) if col in new_cols]) + + # convert df + data = data.set_index(self.identifier_name, drop=False) + data = data.fillna("") + data = data.astype(str) # update the search index data - self.update_index(new_df) + self.update_index(data) def remove_identifier(self, identifier, logging=True) -> None: """Remove this identifier from self.df and the search index. @@ -288,37 +298,40 @@ def remove_identifier(self, identifier, logging=True) -> None: log.debug(f"Search index updated in {time() - t:.2f} seconds " f"for 1 removed item ({len(self.df)} items currently).") - def change_identifier(self, identifier, data: dict) -> None: + def change_identifier(self, identifier, data: pd.DataFrame) -> None: """Change this identifier. - identifier is expected to be a unique identifier that is in use - data is expected to be a dict of column names and data that change - - only changed data needs to be supplied + identifier must be an identifier that is in use + data must be a dataframe of 1 row with all change data + data is overwritten with the new data in 'data', columns not given remain unchanged """ - # make sure the identifier exists + + # make sure only 1 change item is given + if len(data) > 1 or len(data) < 1: + raise Exception( + f"change data must be for exactly 1 identifier, but {len(data)} items were given.") + # make sure correct use of identifier if identifier not in self.df.index.to_list(): raise Exception( f"Identifier '{identifier}' does not exist in the search data, use an existing identifier or use the add_identifier function.") - if self.identifier_name in data.keys() and data[self.identifier_name] != identifier: + if self.identifier_name in data.columns and data[self.identifier_name].to_list() != [identifier]: raise Exception( "Identifier field cannot be changed, first remove item and then add new identifier") if "query_col" in data.keys(): log.debug( f"Field 'query_col' is a protected field for search engine and will be ignored for changing {identifier}") - # get existing data - update_data = {col: self.df.loc[identifier, col] for col in self.df.columns} - del update_data["query_col"] # overwrite new data where relevant - for field, value in data.items(): - update_data[field] = value + update_data = self.df.loc[[identifier], self.columns] + data = data.reset_index(drop=True) + for col in data.columns: + value = data.loc[0, col] + update_data[col] = [value] # remove the entry self.remove_identifier(identifier, logging=False) - - # add entry with new data + # add entry with updated data self.add_identifier(update_data) # +++ Search diff --git a/tests/test_search.py b/tests/test_search.py index 036e6c864..620c5c3b8 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -138,42 +138,43 @@ def test_search_add_identifier(): df = data_for_test() # create base item to add - new_base_item = { - "id": "i", - "col1": "coal production", - "col2": "coal production" - } + new_base_item = pd.DataFrame([ + ["i", "coal production", "coal production"], + ], + columns=["id", "col1", "col2"]) - # use mismatched identifier and fail + # use existing identifier and fail se = SearchEngine(df, identifier_name="id") + wrong_id = new_base_item.copy() + wrong_id.iloc[0, 0] = "a" with pytest.raises(Exception): - se.add_identifier(identifier="j", data=new_base_item) + se.add_identifier(wrong_id) - # use existing identifier and fail + # add data without identifier column se = SearchEngine(df, identifier_name="id") - wrong_id = new_base_item.copy() - wrong_id["id"] = "a" + no_id = new_base_item.copy() + del no_id["id"] with pytest.raises(Exception): - se.add_identifier(identifier="a", data=wrong_id) + se.add_identifier(no_id) - # use column too many (should be removed) + # use column more (and find data in new col) se = SearchEngine(df, identifier_name="id") col_more = new_base_item.copy() - col_more["col3"] = "word" - se.add_identifier(identifier="i", data=col_more) - assert "col3" not in se.df.columns + col_more["col3"] = ["potatoes"] + se.add_identifier(col_more) + assert se.search("potatoes") == ["i"] # use column less (should be filled with empty string) se = SearchEngine(df, identifier_name="id") col_less = new_base_item.copy() del col_less["col2"] - se.add_identifier(identifier="i", data=col_less) + se.add_identifier(col_less) assert se.df.loc["i", "col2"] == "" # do search, add item and verify results are different se = SearchEngine(df, identifier_name="id") assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] - se.add_identifier(identifier="i", data=new_base_item) + se.add_identifier(new_base_item) assert se.search("coal production") == ["i", "a", "c", "b", "d", "h", "f", "g"] @@ -198,23 +199,22 @@ def test_search_change_identifier(): df = data_for_test() # create base item to add - edit_data = { - "id": "a", - "col1": "cant find me anymore", - "col2": "something different" - } + edit_data = pd.DataFrame([ + ["a", "cant find me anymore", "something different"], + ], + columns=["id", "col1", "col2"]) # use non-existent identifier and fail se = SearchEngine(df, identifier_name="id") missing_id = edit_data.copy() - missing_id["id"] = "i" + missing_id["id"] = ["i"] with pytest.raises(Exception): se.change_identifier(identifier="i", data=missing_id) # use mismatched identifier and fail se = SearchEngine(df, identifier_name="id") wrong_id = edit_data.copy() - wrong_id["id"] = "i" + wrong_id["id"] = ["i"] with pytest.raises(Exception): se.change_identifier(identifier="a", data=wrong_id) @@ -224,9 +224,9 @@ def test_search_change_identifier(): se.change_identifier(identifier="a", data=edit_data) assert se.search("coal production") == ["c", "b", "d", "h", "f", "g"] # now change the same item partially and verify results are different - new_edit_data = { - "id": "a", - "col1": "coal" - } + new_edit_data = pd.DataFrame([ + ["a", "coal"], + ], + columns=["id", "col1"]) se.change_identifier(identifier="a", data=new_edit_data) assert se.search("coal production") == ["c", "b", "d", "h", "a", "f", "g"] From 2d6ca0f94659967808cafa0c062a881875b99ab2 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Wed, 3 Sep 2025 11:38:33 +0200 Subject: [PATCH 011/267] update add/change identifier (and tests) to accept dataframes instead of dicts --- tests/test_search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_search.py b/tests/test_search.py index 620c5c3b8..52199a64e 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -44,7 +44,7 @@ def test_reverse_dict(): w2i = se.reverse_dict_many_to_one(se.identifier_to_word) assert w2i == se.word_to_identifier - # reverse same and verify is same as input + # reverse again and verify is same as original i2w = se.reverse_dict_many_to_one(w2i) assert i2w == se.identifier_to_word From 04053ab82de07d96d49f15a1407015d7b5a43ecd Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Wed, 3 Sep 2025 11:40:20 +0200 Subject: [PATCH 012/267] move searchengine.py to bwutils instead of subfolder --- activity_browser/bwutils/search/__init__.py | 1 - activity_browser/bwutils/{search => }/searchengine.py | 0 2 files changed, 1 deletion(-) delete mode 100644 activity_browser/bwutils/search/__init__.py rename activity_browser/bwutils/{search => }/searchengine.py (100%) diff --git a/activity_browser/bwutils/search/__init__.py b/activity_browser/bwutils/search/__init__.py deleted file mode 100644 index 85e30c9be..000000000 --- a/activity_browser/bwutils/search/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .searchengine import SearchEngine, MetaDataSearchEngine \ No newline at end of file diff --git a/activity_browser/bwutils/search/searchengine.py b/activity_browser/bwutils/searchengine.py similarity index 100% rename from activity_browser/bwutils/search/searchengine.py rename to activity_browser/bwutils/searchengine.py From 478ed5dfb0680abd7f498517aab512dfab9a06d1 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Wed, 3 Sep 2025 11:41:08 +0200 Subject: [PATCH 013/267] move searchengine.py to bwutils instead of subfolder --- activity_browser/bwutils/__init__.py | 2 +- tests/test_search.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/activity_browser/bwutils/__init__.py b/activity_browser/bwutils/__init__.py index 5e97b3a2f..18839d5ac 100644 --- a/activity_browser/bwutils/__init__.py +++ b/activity_browser/bwutils/__init__.py @@ -13,7 +13,7 @@ from .montecarlo import MonteCarloLCA from .multilca import MLCA, Contributions from .pedigree import PedigreeMatrix -from .search import SearchEngine, MetaDataSearchEngine +from .searchengine import SearchEngine, MetaDataSearchEngine from .sensitivity_analysis import GlobalSensitivityAnalysis from .superstructure import SuperstructureContributions, SuperstructureMLCA from .uncertainty import (CFUncertaintyInterface, ExchangeUncertaintyInterface, diff --git a/tests/test_search.py b/tests/test_search.py index 52199a64e..2bb038124 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,6 +1,6 @@ import pytest import pandas as pd -from activity_browser.bwutils.search import SearchEngine +from activity_browser.bwutils import SearchEngine def data_for_test(): From 1c1300728d439d2808251a382929430b93d6c40e Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Wed, 3 Sep 2025 12:08:54 +0200 Subject: [PATCH 014/267] move searchengine files --- .../bwutils/searchengine/__init__.py | 2 + .../{searchengine.py => searchengine/base.py} | 185 ----------------- .../bwutils/searchengine/metadata_search.py | 196 ++++++++++++++++++ 3 files changed, 198 insertions(+), 185 deletions(-) create mode 100644 activity_browser/bwutils/searchengine/__init__.py rename activity_browser/bwutils/{searchengine.py => searchengine/base.py} (78%) create mode 100644 activity_browser/bwutils/searchengine/metadata_search.py diff --git a/activity_browser/bwutils/searchengine/__init__.py b/activity_browser/bwutils/searchengine/__init__.py new file mode 100644 index 000000000..7a7eae9c1 --- /dev/null +++ b/activity_browser/bwutils/searchengine/__init__.py @@ -0,0 +1,2 @@ +from base import SearchEngine +from metadata_search import MetaDataSearchEngine diff --git a/activity_browser/bwutils/searchengine.py b/activity_browser/bwutils/searchengine/base.py similarity index 78% rename from activity_browser/bwutils/searchengine.py rename to activity_browser/bwutils/searchengine/base.py index aaf090eb6..7f9d8158e 100644 --- a/activity_browser/bwutils/searchengine.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -746,188 +746,3 @@ def search(self, text) -> list: log.debug( f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") return identifiers - - -class MetaDataSearchEngine(SearchEngine): - def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: - """Overwritten for extra database specific reduction of results. - """ - n_q_grams = len(q_grams) - - matches = {} - - # find words that match our q-grams - for q_gram in q_grams: - if words := self.q_gram_to_word.get(q_gram, False): - # q_gram exists in our search index - for word in words: - if isinstance(self.database_ids, set): - # DATABASE SPECIFIC now filter on whether word is in the database - in_db = False - for _id in self.word_to_identifier[word]: - if _id in self.database_ids: - in_db = True - break - else: - in_db = True - if in_db: - matches[word] = matches.get(word, 0) + words[word] - - # if we find no results, return an empty dataframe - if len(matches) == 0: - return pd.DataFrame({"word": [], "matches": []}) - - # otherwise, create a dataframe and - # reduce search results to most relevant results - matches = {"word": matches.keys(), "matches": matches.values()} - matches = pd.DataFrame(matches) - max_q = max(matches["matches"]) # this has the most matching q-grams - - # determine how many results we want to keep based on how good our results are - min_q = max(max_q * 0.32, # have at least a third of q-grams of best match or... - max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? - 1)) # okay just do 1 q-gram if there are no more in the word - - matches = matches[matches["matches"] >= min_q] - matches = matches.sort_values(by="matches", ascending=False) - matches = matches.reset_index(drop=True) - - return matches.iloc[:min(len(matches), 2500), :] # return at most this many results - - def fuzzy_search(self, text: str) -> list: - """Overwritten for extra database specific reduction of results. - """ - queries = self.build_queries(text) - - # make list of unique original words - orig_words = OrderedDict() - for word in text.split(" "): - orig_words[word] = False - orig_words = orig_words.keys() - orig_words = {self.clean_text(word) for word in orig_words} - - # order the queries by the amount of words they contain - # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space - queries_by_size = OrderedDict() - longest_query = max([len(q) for q in queries]) - for query_len in range(1, longest_query + 1): - queries_by_size[query_len] = [q for q in queries if len(q) == query_len] - - # first handle queries of length 1 - query_to_identifier = self.search_size_1(queries_by_size[1], orig_words) - - # DATABASE SPECIFIC ensure all identifiers are in the database - if isinstance(self.database_ids, set): - new_q2i = {} - for word, _ids in query_to_identifier.items(): - keep = set.intersection(set(_ids.keys()), self.database_ids) - new_id_counter = Counter() - for _id in keep: - new_id_counter[_id] = _ids[_id] - if len(new_id_counter) > 0: - new_q2i[word] = new_id_counter - query_to_identifier = new_q2i - - # get all results into a df, we rank further later - all_identifiers = set() - for id_list in [id_list for id_list in query_to_identifier.values()]: - all_identifiers.update(id_list) - search_df = self.df.loc[list(all_identifiers)] - - # now, we search for combinations of query words and get only those identifiers - # we then reduce de search_df further for only those matching identifiers - # we then search the permutations of that set of words - for q_len, query_set in queries_by_size.items(): - if q_len == 1: - # we already did these above - continue - for query in query_set: - - # get the intersection of all identifiers - # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query - # this ensures we only ever search data where ALL items occur to substantially reduce search-space - # finally, make this a Counter (with each item=1) so we can properly weigh things later - query_identifier_set = set.intersection(*[set(query_to_identifier.get(q_word)) for q_word in query if - query_to_identifier.get(q_word, False)]) - if len(query_identifier_set) == 0: - # there is no match for this combination of query words, skip - break - - # now we convert the query identifiers to a Counter of 'occurrence', - # where we weigh queries with only original words higher - query_identifiers = Counter() - for identifier in query_identifier_set: - weight = 0 - for query_word in query: - weight += query_to_identifier[query_word][identifier] - - query_identifiers[identifier] = weight - - # we now add these identifiers to a counter for this query name, - query_name = " ".join(query) - - weight = self.base_weight * q_len - query_to_identifier[query_name] = self.weigh_identifiers(query_identifiers, weight, Counter()) - - # now search for all permutations of this query combined with a space - query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] - for query_perm in permutations(query): - mask = self.filter_dataframe(query_df, " ".join(query_perm), search_columns=["query_col"]) - new_df = query_df.loc[mask].reset_index(drop=True) - if len(new_df) == 0: - # there is no match for this permutation of words, skip - continue - new_id_list = new_df[self.identifier_name] - - new_ids = Counter() - for new_id in new_id_list: - new_ids[new_id] = query_identifiers[new_id] - - # we weigh a combination of words that is next also to each other even higher than just the words separately - query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, - query_to_identifier[query_name]) - # now finally, move to one object sorted list by highest score - all_identifiers = Counter() - for identifiers in query_to_identifier.values(): - all_identifiers += identifiers - - # now sort on highest weights and make list type - sorted_identifiers = [identifier[0] for identifier in all_identifiers.most_common()] - return sorted_identifiers - - def search(self, text, database: Optional[str] = None) -> list: - """Search the dataframe on this text, return a sorted list of identifiers.""" - t = time() - - if len(text) == 0: - log.debug(f"Empty search, returned all items") - return self.df.index.to_list() - - # get the set of ids that is in this database - if database is not None: - self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) - else: - self.database_ids = None - - fuzzy_identifiers = self.fuzzy_search(text) - if len(fuzzy_identifiers) == 0: - log.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - return [] - - # take the fuzzy search sub-set of data and search it literally - df = self.df.loc[fuzzy_identifiers].copy() - - literal_identifiers = self.literal_search(text, df) - if len(literal_identifiers) == 0: - log.debug( - f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - return fuzzy_identifiers - - # append any fuzzy identifiers that were not found in the literal search - remaining_fuzzy_identifiers = [ - _id for _id in fuzzy_identifiers if _id not in set(literal_identifiers)] - identifiers = literal_identifiers + remaining_fuzzy_identifiers - - log.debug( - f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - return identifiers diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py new file mode 100644 index 000000000..01a5f93aa --- /dev/null +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -0,0 +1,196 @@ +from itertools import permutations +from collections import Counter, OrderedDict +from logging import getLogger +from time import time +from typing import Optional +import pandas as pd + +from activity_browser.bwutils.searchengine import SearchEngine + + +log = getLogger(__name__) + + +class MetaDataSearchEngine(SearchEngine): + def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: + """Overwritten for extra database specific reduction of results. + """ + n_q_grams = len(q_grams) + + matches = {} + + # find words that match our q-grams + for q_gram in q_grams: + if words := self.q_gram_to_word.get(q_gram, False): + # q_gram exists in our search index + for word in words: + if isinstance(self.database_ids, set): + # DATABASE SPECIFIC now filter on whether word is in the database + in_db = False + for _id in self.word_to_identifier[word]: + if _id in self.database_ids: + in_db = True + break + else: + in_db = True + if in_db: + matches[word] = matches.get(word, 0) + words[word] + + # if we find no results, return an empty dataframe + if len(matches) == 0: + return pd.DataFrame({"word": [], "matches": []}) + + # otherwise, create a dataframe and + # reduce search results to most relevant results + matches = {"word": matches.keys(), "matches": matches.values()} + matches = pd.DataFrame(matches) + max_q = max(matches["matches"]) # this has the most matching q-grams + + # determine how many results we want to keep based on how good our results are + min_q = max(max_q * 0.32, # have at least a third of q-grams of best match or... + max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? + 1)) # okay just do 1 q-gram if there are no more in the word + + matches = matches[matches["matches"] >= min_q] + matches = matches.sort_values(by="matches", ascending=False) + matches = matches.reset_index(drop=True) + + return matches.iloc[:min(len(matches), 2500), :] # return at most this many results + + def fuzzy_search(self, text: str) -> list: + """Overwritten for extra database specific reduction of results. + """ + queries = self.build_queries(text) + + # make list of unique original words + orig_words = OrderedDict() + for word in text.split(" "): + orig_words[word] = False + orig_words = orig_words.keys() + orig_words = {self.clean_text(word) for word in orig_words} + + # order the queries by the amount of words they contain + # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space + queries_by_size = OrderedDict() + longest_query = max([len(q) for q in queries]) + for query_len in range(1, longest_query + 1): + queries_by_size[query_len] = [q for q in queries if len(q) == query_len] + + # first handle queries of length 1 + query_to_identifier = self.search_size_1(queries_by_size[1], orig_words) + + # DATABASE SPECIFIC ensure all identifiers are in the database + if isinstance(self.database_ids, set): + new_q2i = {} + for word, _ids in query_to_identifier.items(): + keep = set.intersection(set(_ids.keys()), self.database_ids) + new_id_counter = Counter() + for _id in keep: + new_id_counter[_id] = _ids[_id] + if len(new_id_counter) > 0: + new_q2i[word] = new_id_counter + query_to_identifier = new_q2i + + # get all results into a df, we rank further later + all_identifiers = set() + for id_list in [id_list for id_list in query_to_identifier.values()]: + all_identifiers.update(id_list) + search_df = self.df.loc[list(all_identifiers)] + + # now, we search for combinations of query words and get only those identifiers + # we then reduce de search_df further for only those matching identifiers + # we then search the permutations of that set of words + for q_len, query_set in queries_by_size.items(): + if q_len == 1: + # we already did these above + continue + for query in query_set: + + # get the intersection of all identifiers + # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query + # this ensures we only ever search data where ALL items occur to substantially reduce search-space + # finally, make this a Counter (with each item=1) so we can properly weigh things later + query_identifier_set = set.intersection(*[set(query_to_identifier.get(q_word)) for q_word in query if + query_to_identifier.get(q_word, False)]) + if len(query_identifier_set) == 0: + # there is no match for this combination of query words, skip + break + + # now we convert the query identifiers to a Counter of 'occurrence', + # where we weigh queries with only original words higher + query_identifiers = Counter() + for identifier in query_identifier_set: + weight = 0 + for query_word in query: + weight += query_to_identifier[query_word][identifier] + + query_identifiers[identifier] = weight + + # we now add these identifiers to a counter for this query name, + query_name = " ".join(query) + + weight = self.base_weight * q_len + query_to_identifier[query_name] = self.weigh_identifiers(query_identifiers, weight, Counter()) + + # now search for all permutations of this query combined with a space + query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] + for query_perm in permutations(query): + mask = self.filter_dataframe(query_df, " ".join(query_perm), search_columns=["query_col"]) + new_df = query_df.loc[mask].reset_index(drop=True) + if len(new_df) == 0: + # there is no match for this permutation of words, skip + continue + new_id_list = new_df[self.identifier_name] + + new_ids = Counter() + for new_id in new_id_list: + new_ids[new_id] = query_identifiers[new_id] + + # we weigh a combination of words that is next also to each other even higher than just the words separately + query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, + query_to_identifier[query_name]) + # now finally, move to one object sorted list by highest score + all_identifiers = Counter() + for identifiers in query_to_identifier.values(): + all_identifiers += identifiers + + # now sort on highest weights and make list type + sorted_identifiers = [identifier[0] for identifier in all_identifiers.most_common()] + return sorted_identifiers + + def search(self, text, database: Optional[str] = None) -> list: + """Search the dataframe on this text, return a sorted list of identifiers.""" + t = time() + + if len(text) == 0: + log.debug(f"Empty search, returned all items") + return self.df.index.to_list() + + # get the set of ids that is in this database + if database is not None: + self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) + else: + self.database_ids = None + + fuzzy_identifiers = self.fuzzy_search(text) + if len(fuzzy_identifiers) == 0: + log.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return [] + + # take the fuzzy search sub-set of data and search it literally + df = self.df.loc[fuzzy_identifiers].copy() + + literal_identifiers = self.literal_search(text, df) + if len(literal_identifiers) == 0: + log.debug( + f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return fuzzy_identifiers + + # append any fuzzy identifiers that were not found in the literal search + remaining_fuzzy_identifiers = [ + _id for _id in fuzzy_identifiers if _id not in set(literal_identifiers)] + identifiers = literal_identifiers + remaining_fuzzy_identifiers + + log.debug( + f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return identifiers From 646b3beeba41ab5b4a43a77fc55e58696847ba9e Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Wed, 3 Sep 2025 12:10:20 +0200 Subject: [PATCH 015/267] move searchengine files --- activity_browser/bwutils/searchengine/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activity_browser/bwutils/searchengine/__init__.py b/activity_browser/bwutils/searchengine/__init__.py index 7a7eae9c1..a3ed1d8e1 100644 --- a/activity_browser/bwutils/searchengine/__init__.py +++ b/activity_browser/bwutils/searchengine/__init__.py @@ -1,2 +1,2 @@ -from base import SearchEngine -from metadata_search import MetaDataSearchEngine +from .base import SearchEngine +from .metadata_search import MetaDataSearchEngine From 39af7634a4ad43cd6aceec76f638989f7198dc4c Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Wed, 3 Sep 2025 13:27:26 +0200 Subject: [PATCH 016/267] metadata and search size logging --- activity_browser/bwutils/metadata.py | 15 +++++++++--- activity_browser/bwutils/searchengine/base.py | 23 +++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/activity_browser/bwutils/metadata.py b/activity_browser/bwutils/metadata.py index 2e665cdd7..b3d6a7967 100644 --- a/activity_browser/bwutils/metadata.py +++ b/activity_browser/bwutils/metadata.py @@ -2,11 +2,13 @@ import itertools import sqlite3 import pickle +import sys from time import time from functools import lru_cache from typing import Set from logging import getLogger +from playhouse.shortcuts import model_to_dict import pandas as pd from qtpy.QtCore import Qt, QObject, Signal, SignalInstance @@ -15,7 +17,7 @@ from bw2data.errors import UnknownObject from bw2data.backends import sqlite3_lci_db, ActivityDataset -from activity_browser.bwutils.search import MetaDataSearchEngine +from activity_browser.bwutils.searchengine import MetaDataSearchEngine from activity_browser import signals @@ -183,6 +185,7 @@ def _get_database(self, db_name: str) -> pd.DataFrame | None: def sync(self) -> None: """Deletes metadata when the project is changed.""" + t = time() log.debug("Synchronizing MetaDataStore") con = sqlite3.connect(sqlite3_lci_db._filepath) @@ -190,8 +193,14 @@ def sync(self) -> None: con.close() self.dataframe = self._parse_df(node_df) - self.init_search() # init search index + size_bytes = sys.getsizeof(self.dataframe) + if size_bytes < 1024 ** 3: + size = f"{size_bytes / (1024 ** 2):.1f} MB" + else: + size = f"{size_bytes / (1024 ** 3):.2f} GB" + log.debug(f"MetaDataStore Synchronized in {time() - t:.2f} seconds for {len(self.dataframe)} items ({size}))") + self.init_search() # init search index self.synced.emit() def _parse_df(self, raw_df: pd.DataFrame) -> pd.DataFrame: @@ -351,7 +360,7 @@ def init_search(self): "product", "reference product", "classifications", "location", "properties" # activity specific ] - MetaDataSearchEngine(self.dataframe, identifier_name="id", searchable_columns=allowed_cols) + self.search_engine = MetaDataSearchEngine(self.dataframe, identifier_name="id", searchable_columns=allowed_cols) AB_metadata = MetaDataStore() diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index 7f9d8158e..293ddd230 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -8,6 +8,7 @@ import pandas as pd import numpy as np import re +import sys log = getLogger(__name__) @@ -128,9 +129,9 @@ def update_dict(update_me: dict, new: dict) -> dict: size_new = len(self.df) size_dif = size_new - size_old - size_msg = (f"{size_dif} changed items at {round(size_dif/(time() - t), 0)} items/sec " - f"({size_new} items currently)") if size_dif > 1 \ - else f"1 changed item ({size_new} items currently)" + size_msg = (f"{size_dif} changed items at {int(round(size_dif/(time() - t), 0))} items/sec " + f"({size_new} items ({self.size_of_index()}) currently)") if size_dif > 1 \ + else f"1 changed item ({size_new} items ({self.size_of_index()}) currently)" log.debug(f"Search index updated in {time() - t:.2f} seconds for {size_msg}.") def clean_text(self, text: str): @@ -214,6 +215,20 @@ def word_in_index(self, word: str) -> bool: f"Given word '{word}' must not contain spaces.") return word in self.word_to_identifier.keys() + def size_of_index(self): + """return the size of the search index in MB or GB.""" + s_df = sys.getsizeof(self.df) + s_i2w = sys.getsizeof(self.identifier_to_word) + s_w2i = sys.getsizeof(self.word_to_identifier) + s_w2q = sys.getsizeof(self.word_to_q_grams) + s_q2w = sys.getsizeof(self.q_gram_to_word) + size_bytes = s_df + s_i2w + s_w2i + s_w2q + s_q2w + + if size_bytes < 1024 ** 3: + return f"{size_bytes / (1024 ** 2):.1f} MB" + else: + return f"{size_bytes / (1024 ** 3):.2f} GB" + # +++ Changes to searchable data def add_identifier(self, data: pd.DataFrame) -> None: @@ -296,7 +311,7 @@ def remove_identifier(self, identifier, logging=True) -> None: if logging: log.debug(f"Search index updated in {time() - t:.2f} seconds " - f"for 1 removed item ({len(self.df)} items currently).") + f"for 1 removed item ({len(self.df)} items ({self.size_of_index()}) currently).") def change_identifier(self, identifier, data: pd.DataFrame) -> None: """Change this identifier. From fad8a06f6d5c3af2668fd7e2a35cf408c98710bf Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Wed, 3 Sep 2025 16:27:11 +0200 Subject: [PATCH 017/267] - Faster results with large data and short queries - solve bracket in wrong place breaking matchfinding --- activity_browser/bwutils/searchengine/base.py | 10 ++++++---- .../bwutils/searchengine/metadata_search.py | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index 293ddd230..618585991 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -257,7 +257,8 @@ def add_identifier(self, data: pd.DataFrame) -> None: if col not in data.columns: data[col] = [""] * len(data) # re-order cols, first existing, then new - new_cols = [col for col in data.columns if col not in self.columns if col not in set(df_cols)] + df_col_set = set(df_cols) + new_cols = [col for col in data.columns if col not in self.columns if col not in df_col_set] data_cols = df_cols + new_cols data = data[data_cols] # re-order new data to be in correct order @@ -285,7 +286,7 @@ def remove_identifier(self, identifier, logging=True) -> None: f"Identifier '{identifier}' does not exist in the search data, cannot remove identifier that do not exist.") # remove from df - self.df.drop(identifier, inplace=True) + self.df = self.df.drop(identifier) # find words that may need to be removed words = self.identifier_to_word[identifier] @@ -547,7 +548,7 @@ def spell_check(self, text: str) -> OrderedDict: for match, num in new.most_common(): if num == prev_num: matches.append(match) - elif num != prev_num and len(matches <= matches_max): + elif num != prev_num and len(matches) <= matches_max: matches.append(match) else: break @@ -754,8 +755,9 @@ def search(self, text) -> list: return fuzzy_identifiers # append any fuzzy identifiers that were not found in the literal search + literal_id_set = set(literal_identifiers) remaining_fuzzy_identifiers = [ - _id for _id in fuzzy_identifiers if _id not in set(literal_identifiers)] + _id for _id in fuzzy_identifiers if _id not in literal_id_set] identifiers = literal_identifiers + remaining_fuzzy_identifiers log.debug( diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py index 01a5f93aa..43332e939 100644 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -187,8 +187,9 @@ def search(self, text, database: Optional[str] = None) -> list: return fuzzy_identifiers # append any fuzzy identifiers that were not found in the literal search + literal_id_set = set(literal_identifiers) remaining_fuzzy_identifiers = [ - _id for _id in fuzzy_identifiers if _id not in set(literal_identifiers)] + _id for _id in fuzzy_identifiers if _id not in literal_id_set] identifiers = literal_identifiers + remaining_fuzzy_identifiers log.debug( From 1aad95b421d15c8ec8e9bd406a5cea47c802689c Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Wed, 3 Sep 2025 18:12:53 +0200 Subject: [PATCH 018/267] Base implementation of better search in ActivitiesProducts table --- activity_browser/bwutils/metadata.py | 7 ++- activity_browser/bwutils/searchengine/base.py | 4 +- .../bwutils/searchengine/metadata_search.py | 20 ++++++- .../layouts/panes/database_products.py | 56 ++++++++++++++++++- activity_browser/ui/widgets/item_model.py | 19 +++++-- activity_browser/ui/widgets/treeview.py | 5 +- 6 files changed, 100 insertions(+), 11 deletions(-) diff --git a/activity_browser/bwutils/metadata.py b/activity_browser/bwutils/metadata.py index b3d6a7967..7e1ff5e1f 100644 --- a/activity_browser/bwutils/metadata.py +++ b/activity_browser/bwutils/metadata.py @@ -5,7 +5,7 @@ import sys from time import time from functools import lru_cache -from typing import Set +from typing import Set, Optional from logging import getLogger from playhouse.shortcuts import model_to_dict @@ -362,5 +362,10 @@ def init_search(self): self.search_engine = MetaDataSearchEngine(self.dataframe, identifier_name="id", searchable_columns=allowed_cols) + def db_search(self, query:str, database: Optional[str] = None, return_counter: bool = False): + return self.search_engine.fuzzy_search(query, database=database, return_counter=return_counter) + + def search(self, query:str): + return self.search_engine.search(query) AB_metadata = MetaDataStore() diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index 618585991..f01b941a7 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -628,7 +628,7 @@ def search_size_1(self, queries: list, original_words: set, orig_word_weight=5, return matched_identifiers - def fuzzy_search(self, text: str) -> list: + def fuzzy_search(self, text: str, return_counter: bool = False) -> list: """Search the dataframe, finding approximate matches and return a list of identifiers, ranked by how well each identifier matches the search text. @@ -728,6 +728,8 @@ def fuzzy_search(self, text: str) -> list: for identifiers in query_to_identifier.values(): all_identifiers += identifiers + if return_counter: + return all_identifiers # now sort on highest weights and make list type sorted_identifiers = [identifier[0] for identifier in all_identifiers.most_common()] return sorted_identifiers diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py index 43332e939..624e2365b 100644 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -57,9 +57,20 @@ def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: return matches.iloc[:min(len(matches), 2500), :] # return at most this many results - def fuzzy_search(self, text: str) -> list: + def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter: bool = False, logging: bool = True) -> list: """Overwritten for extra database specific reduction of results. """ + if len(text) == 0: + log.debug(f"Empty search, returned all items") + return self.df.index.to_list() + t = time() + + # DATABASE SPECIFIC get the set of ids that is in this database + if database is not None: + self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) + else: + self.database_ids = None + queries = self.build_queries(text) # make list of unique original words @@ -154,6 +165,11 @@ def fuzzy_search(self, text: str) -> list: for identifiers in query_to_identifier.values(): all_identifiers += identifiers + if logging: + log.debug( + f"Found {len(all_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + if return_counter: + return all_identifiers # now sort on highest weights and make list type sorted_identifiers = [identifier[0] for identifier in all_identifiers.most_common()] return sorted_identifiers @@ -172,7 +188,7 @@ def search(self, text, database: Optional[str] = None) -> list: else: self.database_ids = None - fuzzy_identifiers = self.fuzzy_search(text) + fuzzy_identifiers = self.fuzzy_search(text, database=database, logging=False) if len(fuzzy_identifiers) == 0: log.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") return [] diff --git a/activity_browser/layouts/panes/database_products.py b/activity_browser/layouts/panes/database_products.py index 4a49851c5..4439e140a 100644 --- a/activity_browser/layouts/panes/database_products.py +++ b/activity_browser/layouts/panes/database_products.py @@ -1,5 +1,6 @@ from logging import getLogger from time import time +from collections import Counter import pandas as pd from qtpy import QtWidgets, QtCore, QtGui @@ -56,6 +57,8 @@ def __init__(self, parent, db_name: str): self.table_view = ProductView(self) self.table_view.setModel(self.model) self.model.setDataFrame(self.build_df()) + self.model.has_external_search = True + self.model.external_col_name = db_name self.search = widgets.ABLineEdit(self) self.search.setMaximumHeight(30) @@ -81,7 +84,11 @@ def connect_signals(self): signals.database.deleted.connect(self.on_database_deleted) self.table_view.filtered.connect(self.search_error) - self.search.textChangedDebounce.connect(self.table_view.setAllFilter) + self.search.textChangedDebounce.connect(self.set_queries) + + def set_queries(self, query: str) -> None: + self.model.set_external_query(query) + self.table_view.setAllFilter(query) def saveState(self): """ @@ -360,6 +367,27 @@ def selected_activities(self) -> [tuple]: items = [i.internalPointer() for i in self.selectedIndexes() if isinstance(i.internalPointer(), ProductItem)] return list({item["activity_key"] for item in items if item["activity_key"] is not None}) + def buildQuery(self) -> str: + queries = ["(index == index)"] + + # query for the column filters + for col in list(self.columnFilters): + if col not in self.model().columns(): + del self.columnFilters[col] + + for col, query in self.columnFilters.items(): + q = f"({col}.astype('str').str.contains('{self.format_query(query)}'))" + queries.append(q) + + # query for the all filter + if self.allFilter.startswith('='): + queries.append(f"({self.allFilter[1:]})") + + query = " & ".join(queries) + log.debug(f"{self.__class__.__name__} built query: {query}") + + return query + class ProductItem(ui.widgets.ABDataItem): """ @@ -454,3 +482,29 @@ def values_from_indices(key: str, indices: list[QtCore.QModelIndex]): continue values.append(item[key]) return values + + def external_search(self, query): + results = AB_metadata.db_search(query, database=self.external_col_name, return_counter=True) + + # extract a dict with 'key' as key and 'id' as values from the metadata + result_ids = set(results.keys()) + # extract df with only result IDs and columns 'id' and 'key' + df = AB_metadata.dataframe[AB_metadata.dataframe["id"].isin(result_ids)].loc[:, ["id", "key"]] + df = df.set_index("key", drop=True) + translate_dict = df.to_dict()["id"] + + # convert the metadata id scores to row id scores + row_scores = Counter() + df = self.dataframe.copy() + act_idx = set(df[df["activity_key"].isin(translate_dict.keys())].index.to_list()) + prd_idx = set(df[df["product_key"].isin(translate_dict.keys())].index.to_list()) + indices = act_idx | prd_idx # combine the two sets ('|' is a set union) + # iterate over the indices + for index in indices: + act_score = results.get(translate_dict.get(df.loc[index, "activity_key"]), 0) + prd_score = results.get(translate_dict.get(df.loc[index, "product_key"]), 0) + row_scores[index] = act_score + prd_score + + # finally only return the indices + sorted_indices = [identifier[0] for identifier in row_scores.most_common()] + return sorted_indices diff --git a/activity_browser/ui/widgets/item_model.py b/activity_browser/ui/widgets/item_model.py index 62c7b040a..696a6f9dc 100644 --- a/activity_browser/ui/widgets/item_model.py +++ b/activity_browser/ui/widgets/item_model.py @@ -26,6 +26,9 @@ def __init__(self, parent=None, dataframe=None): self.sort_column: int = 0 # column that is currently sorted self.sort_order: Qt.SortOrder = Qt.SortOrder.AscendingOrder self._query = "" # Pandas query currently applied to the dataframe + self.has_external_search = False + self._external_query = "" + self.external_col_name = "" self.setDataFrame(self.dataframe) @@ -192,7 +195,11 @@ def endResetModel(self): # apply any queries to the dataframe if q := self.query(): - df = self.dataframe.query(q).reset_index(drop=True).copy() + df = self.dataframe.copy() + if self.has_external_search and self._external_query != "": + indices = self.external_search(self._external_query) + df = df.loc[indices] + df = df.query(q).reset_index(drop=True) else: df = self.dataframe.copy() @@ -271,11 +278,15 @@ def setQuery(self, query: str): self._query = query self.endResetModel() + def set_external_query(self, query: str): + if not query.startswith("="): + self._external_query = query + + def external_search(self, query): + NotImplementedError + def hasChildren(self, parent: QtCore.QModelIndex): item = parent.internalPointer() if isinstance(item, ABAbstractItem): return item.has_children() return super().hasChildren(parent) - - - diff --git a/activity_browser/ui/widgets/treeview.py b/activity_browser/ui/widgets/treeview.py index 89cb49aa0..36222b1bb 100644 --- a/activity_browser/ui/widgets/treeview.py +++ b/activity_browser/ui/widgets/treeview.py @@ -6,6 +6,7 @@ from qtpy.QtCore import Qt from .item_model import ABItemModel +from activity_browser.ui import widgets log = getLogger(__name__) @@ -25,11 +26,11 @@ def __init__(self, pos: QtCore.QPoint, view: "ABTreeView"): col_index = view.columnAt(pos.x()) col_name = model.columns()[col_index] - search_box = QtWidgets.QLineEdit(self) + search_box = widgets.ABLineEdit(self) search_box.setText(view.columnFilters.get(col_name, "")) search_box.setPlaceholderText("Search") search_box.selectAll() - search_box.textChanged.connect(lambda query: view.setColumnFilter(col_name, query)) + search_box.textChangedDebounce.connect(lambda query: view.setColumnFilter(col_name, query)) widget_action = QtWidgets.QWidgetAction(self) widget_action.setDefaultWidget(search_box) self.addAction(widget_action) From 5b0a965670edc6951efd4a464f02c0aa199073bf Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Thu, 4 Sep 2025 08:21:48 +0200 Subject: [PATCH 019/267] check all newly added items are unique --- activity_browser/bwutils/searchengine/base.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index f01b941a7..d975726c2 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -244,13 +244,18 @@ def add_identifier(self, data: pd.DataFrame) -> None: raise Exception( f"Identifier column '{self.identifier_name}' not in new data, impossible to add data without identifier") - # make sure we the identifier does not yet exist + # make sure we the new identifiers do not yet exist existing_ids = set(self.df.index.to_list()) for identifier in data[self.identifier_name]: if identifier in existing_ids: raise Exception( f"Identifier '{identifier}' is already in use, use a different identifier or use the change_identifier function.") + # make sure all new identifiers given are unique + if data[self.identifier_name].nunique() != data.shape[0]: + raise KeyError( + f"Identifier column {self.identifier_name} must only contain unique values. Found {data[self.identifier_name].nunique()} unique values for length {data.shape[0]}") + df_cols = self.columns # add cols to new data that are missing for col in df_cols: From 9ee34503703a83d2d50bd6e5024b74009a1c7c09 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Thu, 4 Sep 2025 16:17:49 +0200 Subject: [PATCH 020/267] dont allow sorting of table when search engine in use --- activity_browser/ui/widgets/item_model.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/activity_browser/ui/widgets/item_model.py b/activity_browser/ui/widgets/item_model.py index 09c9d7fc3..3a2fd6d74 100644 --- a/activity_browser/ui/widgets/item_model.py +++ b/activity_browser/ui/widgets/item_model.py @@ -203,13 +203,14 @@ def endResetModel(self): else: df = self.dataframe.copy() - if not self.sort_column > len(self.columns()) - 1: - # apply the sorting - df.sort_values( - by=self.columns()[self.sort_column], - ascending=(self.sort_order == Qt.SortOrder.AscendingOrder), - inplace=True, ignore_index=True - ) + if not (self.has_external_search and self._external_query != ""): + if not self.sort_column > len(self.columns()) - 1: + # apply the sorting + df.sort_values( + by=self.columns()[self.sort_column], + ascending=(self.sort_order == Qt.SortOrder.AscendingOrder), + inplace=True, ignore_index=True + ) # rebuild the ABItem tree self.root = self.branchItemClass("root") @@ -281,6 +282,8 @@ def setQuery(self, query: str): def set_external_query(self, query: str): if not query.startswith("="): self._external_query = query + else: + self._external_query = "" def external_search(self, query): NotImplementedError From e92d298dce86a11bad237cae3821a99bdf3beac7 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Thu, 4 Sep 2025 16:19:32 +0200 Subject: [PATCH 021/267] resolve search bug with multiple typos not working --- activity_browser/bwutils/searchengine/base.py | 25 ++++++++++++------- .../bwutils/searchengine/metadata_search.py | 24 ++++++++++++------ tests/test_search.py | 6 +++++ 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index d975726c2..75b81424f 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -43,8 +43,8 @@ def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: l log.debug(f"SearchEngine initializing for {len(df)} items") # compile regex patterns for cleaning - self.SUB_PATTERN = re.compile(r"[,\(\)\[\]'\"]") # for replacing with empty string - self.SPACE_PATTERN = re.compile(r"[-−:;]") # for replacing with space + self.SUB_PATTERN = re.compile(r"[,\(\)\[\]'\"…]") # for replacing with empty string + self.SPACE_PATTERN = re.compile(r"[-−:;/+]") # for replacing with space self.ONE_SPACE_PATTERN = re.compile(r"\s+") # for replacing multiple white space with 1 space self.q = 2 # character length of q grams @@ -471,9 +471,10 @@ def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: max_q = max(matches["matches"]) # this has the most matching q-grams # determine how many results we want to keep based on how good our results are - min_q = max(max_q * 0.32, # have at least a third of q-grams of best match or... - max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? - 1)) # okay just do 1 q-gram if there are no more in the word + min_q = min(max(max_q * 0.32, # have at least a third of q-grams of best match or... + max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? + 1)), # okay just do 1 q-gram if there are no more in the word + max_q) # never have min_q be over max_q matches = matches[matches["matches"] >= min_q] matches = matches.sort_values(by="matches", ascending=False) @@ -650,6 +651,7 @@ def fuzzy_search(self, text: str, return_counter: bool = False) -> list: Finally, all found identifiers are sorted on their weight and returned. """ + text = text.strip() queries = self.build_queries(text) @@ -684,13 +686,16 @@ def fuzzy_search(self, text: str, return_counter: bool = False) -> list: # we already did these above continue for query in query_set: - # get the intersection of all identifiers # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query # this ensures we only ever search data where ALL items occur to substantially reduce search-space # finally, make this a Counter (with each item=1) so we can properly weigh things later - query_identifier_set = set.intersection(*[set(query_to_identifier.get(q_word)) for q_word in query if - query_to_identifier.get(q_word, False)]) + query_id_sets = [set(query_to_identifier.get(q_word)) for q_word in query if + query_to_identifier.get(q_word, False)] + if len(query_id_sets) > 0: + query_identifier_set = set.intersection(*query_id_sets) + else: + query_identifier_set = set() if len(query_identifier_set) == 0: # there is no match for this combination of query words, skip break @@ -701,7 +706,8 @@ def fuzzy_search(self, text: str, return_counter: bool = False) -> list: for identifier in query_identifier_set: weight = 0 for query_word in query: - weight += query_to_identifier[query_word][identifier] + # if the query_word and identifier combination exist get score, otherwise 0 + weight += query_to_identifier.get(query_word, {}).get(identifier, 0) query_identifiers[identifier] = weight @@ -742,6 +748,7 @@ def fuzzy_search(self, text: str, return_counter: bool = False) -> list: def search(self, text) -> list: """Search the dataframe on this text, return a sorted list of identifiers.""" t = time() + text = text.strip() if len(text) == 0: log.debug(f"Empty search, returned all items") diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py index 624e2365b..c09c28aa8 100644 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -47,9 +47,10 @@ def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: max_q = max(matches["matches"]) # this has the most matching q-grams # determine how many results we want to keep based on how good our results are - min_q = max(max_q * 0.32, # have at least a third of q-grams of best match or... - max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? - 1)) # okay just do 1 q-gram if there are no more in the word + min_q = min(max(max_q * 0.32, # have at least a third of q-grams of best match or... + max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? + 1)), # okay just do 1 q-gram if there are no more in the word + max_q) # never have min_q be over max_q matches = matches[matches["matches"] >= min_q] matches = matches.sort_values(by="matches", ascending=False) @@ -60,10 +61,12 @@ def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter: bool = False, logging: bool = True) -> list: """Overwritten for extra database specific reduction of results. """ + t = time() + text = text.strip() + if len(text) == 0: log.debug(f"Empty search, returned all items") return self.df.index.to_list() - t = time() # DATABASE SPECIFIC get the set of ids that is in this database if database is not None: @@ -116,13 +119,16 @@ def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter # we already did these above continue for query in query_set: - # get the intersection of all identifiers # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query # this ensures we only ever search data where ALL items occur to substantially reduce search-space # finally, make this a Counter (with each item=1) so we can properly weigh things later - query_identifier_set = set.intersection(*[set(query_to_identifier.get(q_word)) for q_word in query if - query_to_identifier.get(q_word, False)]) + query_id_sets = [set(query_to_identifier.get(q_word)) for q_word in query if + query_to_identifier.get(q_word, False)] + if len(query_id_sets) > 0: + query_identifier_set = set.intersection(*query_id_sets) + else: + query_identifier_set = set() if len(query_identifier_set) == 0: # there is no match for this combination of query words, skip break @@ -133,7 +139,8 @@ def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter for identifier in query_identifier_set: weight = 0 for query_word in query: - weight += query_to_identifier[query_word][identifier] + # if the query_word and identifier combination exist get score, otherwise 0 + weight += query_to_identifier.get(query_word, {}).get(identifier, 0) query_identifiers[identifier] = weight @@ -177,6 +184,7 @@ def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter def search(self, text, database: Optional[str] = None) -> list: """Search the dataframe on this text, return a sorted list of identifiers.""" t = time() + text = text.strip() if len(text) == 0: log.debug(f"Empty search, returned all items") diff --git a/tests/test_search.py b/tests/test_search.py index 2bb038124..6d63c14ee 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -127,6 +127,12 @@ def test_search_base(): assert se.search("coal") == ["a", "h", "c", "b", "d", "g", "f"] # do search on other term assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] + # do search on typo + assert se.search("cola") == ["a", "c", "h", "b", "d", "f", "g"] + # do search on longer typo + assert se.search("cola production") == ["c", "a", "b", "d", "h", "f", "g"] + # do search on something we will definitely not find + assert se.search("dontFindThis") == [] # init search class with 1 col searchable se = SearchEngine(df, identifier_name="id", searchable_columns=["col2"]) From 83ae1621f6329679c190ff7b97beab0eb1600008 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Thu, 4 Sep 2025 18:01:59 +0200 Subject: [PATCH 022/267] First version of autocomplete --- activity_browser/bwutils/metadata.py | 5 ++ .../bwutils/searchengine/metadata_search.py | 72 +++++++++++++++++++ .../layouts/panes/database_products.py | 3 +- activity_browser/ui/widgets/__init__.py | 2 +- activity_browser/ui/widgets/line_edit.py | 36 +++++++++- 5 files changed, 115 insertions(+), 3 deletions(-) diff --git a/activity_browser/bwutils/metadata.py b/activity_browser/bwutils/metadata.py index 7e1ff5e1f..70ab0606f 100644 --- a/activity_browser/bwutils/metadata.py +++ b/activity_browser/bwutils/metadata.py @@ -368,4 +368,9 @@ def db_search(self, query:str, database: Optional[str] = None, return_counter: b def search(self, query:str): return self.search_engine.search(query) + def auto_complete(self, word:str, database: Optional[str] = None): + word = self.search_engine.clean_text(word) + completions = self.search_engine.auto_complete(word, database) + return completions + AB_metadata = MetaDataStore() diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py index c09c28aa8..ef6162723 100644 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -12,6 +12,78 @@ class MetaDataSearchEngine(SearchEngine): + + def auto_complete(self, text: str, database) -> OrderedDict: + """Based on spellchecker, make more useful for autocompletions + """ + if database is not None: + self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) + else: + self.database_ids = None + + count_occurence = lambda x: sum(self.word_to_identifier[x].values()) # count occurences of a word + + word_results = OrderedDict() + + matches_min = 3 # ideally we have at least this many alternatives + matches_max = 10 # ideally don't much more than this many matches + always_accept_this = 1 # values of this edit distance or lower always accepted + never_accept_this = 4 # values this edit distance or over always rejected + + # make list of unique words + words = OrderedDict() + for word in text.split(" "): + words[word] = False + words = words.keys() + + words = [self.clean_text(word) for word in words] + + for word in words: + # first, find possible matches quickly + q_grams = self.text_to_positional_q_gram(word) + possible_matches = self.find_q_gram_matches(set(q_grams)) + + matches = [] + first_matches = Counter() + other_matches = {} + + # now, refine with edit distance + for row in possible_matches.itertuples(): + + edit_distance = self.osa_distance(word, row[1], cutoff=never_accept_this) + + if edit_distance == 0: + continue # we are looking for alternatives only, not the exact word + elif edit_distance <= always_accept_this: + first_matches[row[1]] = count_occurence(row[1]) + elif edit_distance < never_accept_this: + if not other_matches.get(edit_distance): + other_matches[edit_distance] = Counter() + other_matches[edit_distance][row[1]] = count_occurence(row[1]) + else: + continue + + # add matches in correct order: + for match, _ in first_matches.most_common(): + matches.append(match) + # if we have fewer matches than goal, add more 'less good' matches + if len(matches) < matches_min: + for i in range(always_accept_this + 1, never_accept_this): + # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives + if new := other_matches.get(i): + prev_num = 10e100 + for match, num in new.most_common(): + if num == prev_num: + matches.append(match) + elif num != prev_num and len(matches) <= matches_max: + matches.append(match) + else: + break + prev_num = num + + word_results[word] = matches + return word_results + def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: """Overwritten for extra database specific reduction of results. """ diff --git a/activity_browser/layouts/panes/database_products.py b/activity_browser/layouts/panes/database_products.py index 4439e140a..48de92aa1 100644 --- a/activity_browser/layouts/panes/database_products.py +++ b/activity_browser/layouts/panes/database_products.py @@ -60,7 +60,8 @@ def __init__(self, parent, db_name: str): self.model.has_external_search = True self.model.external_col_name = db_name - self.search = widgets.ABLineEdit(self) + self.search = widgets.MetaDataAutoCompleteLineEdit(self) + self.search.database_name = db_name self.search.setMaximumHeight(30) self.search.setPlaceholderText("Quick Search") diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index f8c0c439b..333811439 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -2,7 +2,7 @@ from .comparison_switch import SwitchComboBox from .cutoff_menu import CutoffMenu from .line_edit import (ABLineEdit, SignalledComboEdit, SignalledLineEdit, - SignalledPlainTextEdit) + SignalledPlainTextEdit, MetaDataAutoCompleteLineEdit) from .treeview import ABTreeView from .item_model import ABItemModel from .item import ABAbstractItem, ABBranchItem, ABDataItem diff --git a/activity_browser/ui/widgets/line_edit.py b/activity_browser/ui/widgets/line_edit.py index 655d269d5..d78c2557b 100644 --- a/activity_browser/ui/widgets/line_edit.py +++ b/activity_browser/ui/widgets/line_edit.py @@ -1,8 +1,10 @@ from qtpy import QtWidgets -from qtpy.QtCore import QTimer, Slot, Signal, SignalInstance +from qtpy.QtCore import QTimer, Slot, Signal, SignalInstance, QStringListModel, Qt from qtpy.QtGui import QTextFormat from qtpy.QtWidgets import QCompleter +from activity_browser.bwutils import AB_metadata + class ABLineEdit(QtWidgets.QLineEdit): textChangedDebounce: SignalInstance = Signal(str) @@ -120,3 +122,35 @@ def __init__(self, items: list[str], parent=None): super().__init__(parent=parent) completer = QCompleter(items, self) self.setCompleter(completer) + +class MetaDataAutoCompleteLineEdit(ABLineEdit): + """Line Edit with MetaDataStore completer attached""" + def __init__(self, parent=None): + super().__init__(parent=parent) + self.database_name = "" + + self.textChanged.connect(self._set_items) + + self.model = QStringListModel() + self.completer = QCompleter(self.model) + self.completer.setPopup(self.completer.popup()) + self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) + self.setCompleter(self.completer) + + def _set_items(self): + text = self.text() + + words = text.split(" ") + if len(words) == 0: + self.model.setStringList([]) + return + + alternatives = AB_metadata.auto_complete(words[-1], database=self.database_name) + alternatives = alternatives[words[-1]][:5] # allow for max n autocompletes + print(alternatives) + + items = [] + for alternative in alternatives: + line = " ".join(words[:-1] + [alternative]) + items.append(line) + self.model.setStringList(items) From 2b61e161329363e4d1ccf0b44d64c0eadc506a01 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Thu, 4 Sep 2025 21:01:33 +0200 Subject: [PATCH 023/267] cache database identifiers for faster results + much faster autocomplete --- .../bwutils/searchengine/metadata_search.py | 132 ++++++++++-------- 1 file changed, 71 insertions(+), 61 deletions(-) diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py index ef6162723..ce8483373 100644 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -12,76 +12,92 @@ class MetaDataSearchEngine(SearchEngine): + def database_id_manager(self, database): + if not hasattr(self, "all_database_ids"): + self.all_database_ids = {} - def auto_complete(self, text: str, database) -> OrderedDict: - """Based on spellchecker, make more useful for autocompletions - """ - if database is not None: + if database_ids := self.all_database_ids.get(database): + self.database_ids = database_ids + elif database is not None: self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) + self.all_database_ids[database] = self.database_ids else: self.database_ids = None + def reset_database_id_manager(self): + del self.all_database_ids + del self.database_ids + + def add_identifier(self, data: pd.DataFrame) -> None: + super().add_identifier(data) + self.reset_database_id_manager() + + def remove_identifier(self, identifier, logging=True) -> None: + super().remove_identifier(identifier, logging=logging) + self.reset_database_id_manager() + + def change_identifier(self, identifier, data: pd.DataFrame) -> None: + super().change_identifier(identifier, data) + self.reset_database_id_manager() + + def auto_complete(self, word: str, database) -> OrderedDict: + """Based on spellchecker, make more useful for autocompletions + """ + self.database_id_manager(database) + count_occurence = lambda x: sum(self.word_to_identifier[x].values()) # count occurences of a word word_results = OrderedDict() - matches_min = 3 # ideally we have at least this many alternatives - matches_max = 10 # ideally don't much more than this many matches - always_accept_this = 1 # values of this edit distance or lower always accepted + matches_min = 2 # ideally we have at least this many alternatives + matches_max = 4 # ideally don't much more than this many matches never_accept_this = 4 # values this edit distance or over always rejected - # make list of unique words - words = OrderedDict() - for word in text.split(" "): - words[word] = False - words = words.keys() - - words = [self.clean_text(word) for word in words] + # first, find possible matches quickly + q_grams = self.text_to_positional_q_gram(word) + possible_matches = self.find_q_gram_matches(set(q_grams)) - for word in words: - # first, find possible matches quickly - q_grams = self.text_to_positional_q_gram(word) - possible_matches = self.find_q_gram_matches(set(q_grams)) + matches = [] + first_matches = Counter() + other_matches = {} - matches = [] - first_matches = Counter() - other_matches = {} + # now, refine with edit distance + for row in possible_matches.itertuples(): - # now, refine with edit distance - for row in possible_matches.itertuples(): + if len(word) > len(row[1]) or word == row[1]: + continue + test_word = row[1][:len(word)] - edit_distance = self.osa_distance(word, row[1], cutoff=never_accept_this) + edit_distance = self.osa_distance(word, test_word, cutoff=min(never_accept_this, len(word))) - if edit_distance == 0: - continue # we are looking for alternatives only, not the exact word - elif edit_distance <= always_accept_this: - first_matches[row[1]] = count_occurence(row[1]) - elif edit_distance < never_accept_this: - if not other_matches.get(edit_distance): - other_matches[edit_distance] = Counter() - other_matches[edit_distance][row[1]] = count_occurence(row[1]) - else: - continue - - # add matches in correct order: - for match, _ in first_matches.most_common(): - matches.append(match) - # if we have fewer matches than goal, add more 'less good' matches - if len(matches) < matches_min: - for i in range(always_accept_this + 1, never_accept_this): - # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives - if new := other_matches.get(i): - prev_num = 10e100 - for match, num in new.most_common(): - if num == prev_num: - matches.append(match) - elif num != prev_num and len(matches) <= matches_max: - matches.append(match) - else: - break - prev_num = num + if edit_distance == 0: + first_matches[row[1]] = count_occurence(row[1]) + elif edit_distance < never_accept_this: + if not other_matches.get(edit_distance): + other_matches[edit_distance] = Counter() + other_matches[edit_distance][row[1]] = count_occurence(row[1]) + else: + continue - word_results[word] = matches + # add matches in correct order: + for match, _ in first_matches.most_common(): + matches.append(match) + # if we have fewer matches than goal, add more 'less good' matches + if len(matches) < matches_min: + for i in range(1, never_accept_this): + # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives + if new := other_matches.get(i): + prev_num = 10e100 + for match, num in new.most_common(): + if num == prev_num: + matches.append(match) + elif num != prev_num and len(matches) <= matches_max: + matches.append(match) + else: + break + prev_num = num + + word_results[word] = matches return word_results def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: @@ -141,10 +157,7 @@ def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter return self.df.index.to_list() # DATABASE SPECIFIC get the set of ids that is in this database - if database is not None: - self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) - else: - self.database_ids = None + self.database_id_manager(database) queries = self.build_queries(text) @@ -263,10 +276,7 @@ def search(self, text, database: Optional[str] = None) -> list: return self.df.index.to_list() # get the set of ids that is in this database - if database is not None: - self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) - else: - self.database_ids = None + self.database_id_manager(database) fuzzy_identifiers = self.fuzzy_search(text, database=database, logging=False) if len(fuzzy_identifiers) == 0: From 64bbcd1082d28c0c8b0a4616043a13c55dfa58f9 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Thu, 4 Sep 2025 22:07:54 +0200 Subject: [PATCH 024/267] Implement proper autocomplete popup --- .../bwutils/searchengine/metadata_search.py | 14 +++--- activity_browser/ui/widgets/line_edit.py | 47 ++++++++++++++++--- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py index ce8483373..5bb01b2a4 100644 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -40,15 +40,16 @@ def change_identifier(self, identifier, data: pd.DataFrame) -> None: super().change_identifier(identifier, data) self.reset_database_id_manager() - def auto_complete(self, word: str, database) -> OrderedDict: + def auto_complete(self, word: str, database) -> list: """Based on spellchecker, make more useful for autocompletions """ + if len(word) <= 2: + return [] + self.database_id_manager(database) count_occurence = lambda x: sum(self.word_to_identifier[x].values()) # count occurences of a word - word_results = OrderedDict() - matches_min = 2 # ideally we have at least this many alternatives matches_max = 4 # ideally don't much more than this many matches never_accept_this = 4 # values this edit distance or over always rejected @@ -63,13 +64,11 @@ def auto_complete(self, word: str, database) -> OrderedDict: # now, refine with edit distance for row in possible_matches.itertuples(): - if len(word) > len(row[1]) or word == row[1]: continue - test_word = row[1][:len(word)] + test_word = row[1][:len(word)] # only find edit distance of first part of word edit_distance = self.osa_distance(word, test_word, cutoff=min(never_accept_this, len(word))) - if edit_distance == 0: first_matches[row[1]] = count_occurence(row[1]) elif edit_distance < never_accept_this: @@ -97,8 +96,7 @@ def auto_complete(self, word: str, database) -> OrderedDict: break prev_num = num - word_results[word] = matches - return word_results + return matches def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: """Overwritten for extra database specific reduction of results. diff --git a/activity_browser/ui/widgets/line_edit.py b/activity_browser/ui/widgets/line_edit.py index d78c2557b..6e85ab11b 100644 --- a/activity_browser/ui/widgets/line_edit.py +++ b/activity_browser/ui/widgets/line_edit.py @@ -123,34 +123,69 @@ def __init__(self, items: list[str], parent=None): completer = QCompleter(items, self) self.setCompleter(completer) + class MetaDataAutoCompleteLineEdit(ABLineEdit): """Line Edit with MetaDataStore completer attached""" + + textChangedAutoCompleteDebounce: SignalInstance = Signal() + _debounce_autocomplete_ms = 75 + def __init__(self, parent=None): super().__init__(parent=parent) + + # debounce timer settings + self._debounce_autocomplete_timer = QTimer(self, singleShot=True) + # self.textChanged.connect(self._set_autocomplete_debounce) + self._debounce_autocomplete_timer.timeout.connect(self._emit_autocomplete_debounce) + self.database_name = "" - self.textChanged.connect(self._set_items) + # trigger autocomplete list update + self.textChangedAutoCompleteDebounce.connect(self._set_items) + # autocompleter settings self.model = QStringListModel() self.completer = QCompleter(self.model) - self.completer.setPopup(self.completer.popup()) - self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) + self.popup = self.completer.popup() + self.popup.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.popup.setMaximumHeight(20) + self.completer.setPopup(self.popup) + self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) # allow all items in popup list + self.setCompleter(self.completer) def _set_items(self): text = self.text() - + self.popup.setMaximumHeight(self.popup.sizeHintForRow(0) * 3 + 2 * self.popup.frameWidth()) words = text.split(" ") if len(words) == 0: self.model.setStringList([]) return alternatives = AB_metadata.auto_complete(words[-1], database=self.database_name) - alternatives = alternatives[words[-1]][:5] # allow for max n autocompletes - print(alternatives) + alternatives = alternatives[:5] # allow for max n autocompletes + print(text, alternatives) items = [] for alternative in alternatives: line = " ".join(words[:-1] + [alternative]) items.append(line) self.model.setStringList(items) + self.completer.complete() + + def _set_autocomplete_debounce(self): + self._debounce_autocomplete_timer.setInterval(self._debounce_autocomplete_ms) + self._debounce_autocomplete_timer.start() + + def _emit_autocomplete_debounce(self): + self.textChangedAutoCompleteDebounce.emit() + + def keyPressEvent(self, event): + if event.key() == Qt.Key_Escape: + if self.completer.popup().isVisible(): + self.completer.popup().hide() + event.accept() + return + super().keyPressEvent(event) + if event.text().strip(): + QTimer.singleShot(0, self._set_autocomplete_debounce) From e76f57c2b417dc9659a8d047a3f6fc65fd40645f Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Fri, 5 Sep 2025 13:39:28 +0200 Subject: [PATCH 025/267] suggestions for currently edited word instead of last word + better autocomplete menu behaviour --- activity_browser/ui/widgets/line_edit.py | 76 ++++++++++-------------- 1 file changed, 31 insertions(+), 45 deletions(-) diff --git a/activity_browser/ui/widgets/line_edit.py b/activity_browser/ui/widgets/line_edit.py index 6e85ab11b..244fa59c5 100644 --- a/activity_browser/ui/widgets/line_edit.py +++ b/activity_browser/ui/widgets/line_edit.py @@ -127,65 +127,51 @@ def __init__(self, items: list[str], parent=None): class MetaDataAutoCompleteLineEdit(ABLineEdit): """Line Edit with MetaDataStore completer attached""" - textChangedAutoCompleteDebounce: SignalInstance = Signal() - _debounce_autocomplete_ms = 75 - def __init__(self, parent=None): super().__init__(parent=parent) - - # debounce timer settings - self._debounce_autocomplete_timer = QTimer(self, singleShot=True) - # self.textChanged.connect(self._set_autocomplete_debounce) - self._debounce_autocomplete_timer.timeout.connect(self._emit_autocomplete_debounce) - self.database_name = "" - # trigger autocomplete list update - self.textChangedAutoCompleteDebounce.connect(self._set_items) - # autocompleter settings self.model = QStringListModel() self.completer = QCompleter(self.model) self.popup = self.completer.popup() self.popup.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.popup.setMaximumHeight(20) self.completer.setPopup(self.popup) - self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) # allow all items in popup list - + # allow all items in popup list + self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) self.setCompleter(self.completer) - def _set_items(self): - text = self.text() - self.popup.setMaximumHeight(self.popup.sizeHintForRow(0) * 3 + 2 * self.popup.frameWidth()) - words = text.split(" ") - if len(words) == 0: + # connect textEdited, this only triggers on user input, not Completer input + self.textEdited.connect(self._set_items) + + def _set_items(self, text=None): + if text is None: + text = self.text() + + # find the start and end of the word under the cursor + cursor_pos = self.cursorPosition() + start = cursor_pos + while start > 0 and text[start - 1] != " ": + start -= 1 + end = cursor_pos + while end < len(text) and text[end] != " ": + end += 1 + current_word = text[start:end] + if not current_word: self.model.setStringList([]) return - alternatives = AB_metadata.auto_complete(words[-1], database=self.database_name) - alternatives = alternatives[:5] # allow for max n autocompletes - print(text, alternatives) - + # get suggestions for the current word + alternatives = AB_metadata.auto_complete(current_word, database=self.database_name) + alternatives = alternatives[:6] # at most 6, though we should get ~3 usually + # replace the current word with each alternative items = [] - for alternative in alternatives: - line = " ".join(words[:-1] + [alternative]) - items.append(line) + for alt in alternatives: + new_text = text[:start] + alt + text[end:] + items.append(new_text) + print(text, items) + self.model.setStringList(items) - self.completer.complete() - - def _set_autocomplete_debounce(self): - self._debounce_autocomplete_timer.setInterval(self._debounce_autocomplete_ms) - self._debounce_autocomplete_timer.start() - - def _emit_autocomplete_debounce(self): - self.textChangedAutoCompleteDebounce.emit() - - def keyPressEvent(self, event): - if event.key() == Qt.Key_Escape: - if self.completer.popup().isVisible(): - self.completer.popup().hide() - event.accept() - return - super().keyPressEvent(event) - if event.text().strip(): - QTimer.singleShot(0, self._set_autocomplete_debounce) + # set correct height now that we have data + self.popup.setMaximumHeight(self.popup.sizeHintForRow(0) * 3 + 2 * self.popup.frameWidth()) + From eeed99277a23df1fb2e386f4f579c82edc779caf Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Fri, 5 Sep 2025 17:24:17 +0200 Subject: [PATCH 026/267] Improve text cleaning regex + autocomplete deals better with key hashes + manage popup height better --- activity_browser/bwutils/searchengine/base.py | 12 +++++++----- .../bwutils/searchengine/metadata_search.py | 13 +++++++++---- activity_browser/ui/widgets/line_edit.py | 6 +++++- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index 75b81424f..1cc8235ee 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -43,9 +43,9 @@ def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: l log.debug(f"SearchEngine initializing for {len(df)} items") # compile regex patterns for cleaning - self.SUB_PATTERN = re.compile(r"[,\(\)\[\]'\"…]") # for replacing with empty string - self.SPACE_PATTERN = re.compile(r"[-−:;/+]") # for replacing with space - self.ONE_SPACE_PATTERN = re.compile(r"\s+") # for replacing multiple white space with 1 space + self.SUB_END_PATTERN = re.compile(r"[,.\"'`)\[\]}\\/\-−_:;+…]+(?=\s|$)") # remove these from end of word + self.SUB_START_PATTERN = re.compile(r"(?:^|\s)[,.\"'`(\[{\\/\-−_:;+]+") # remove these from start of word + self.ONE_SPACE_PATTERN = re.compile(r"\s+") # remove these multiple whitespaces self.q = 2 # character length of q grams self.base_weight = 10 # base weighting for sorting results @@ -136,8 +136,10 @@ def update_dict(update_me: dict, new: dict) -> dict: def clean_text(self, text: str): """Clean a string so it doesn't contain weird characters or multiple spaces etc.""" - text = self.SUB_PATTERN.sub("", text.lower()) - text = self.SPACE_PATTERN.sub(" ", text) + text = text.lower() + text = self.SUB_END_PATTERN.sub("", text) + text = self.SUB_START_PATTERN.sub(" ", text) + text = self.ONE_SPACE_PATTERN.sub(" ", text).strip() return text diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py index 5bb01b2a4..33bdc34e8 100644 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -43,7 +43,7 @@ def change_identifier(self, identifier, data: pd.DataFrame) -> None: def auto_complete(self, word: str, database) -> list: """Based on spellchecker, make more useful for autocompletions """ - if len(word) <= 2: + if len(word) <= 1: return [] self.database_id_manager(database) @@ -52,7 +52,7 @@ def auto_complete(self, word: str, database) -> list: matches_min = 2 # ideally we have at least this many alternatives matches_max = 4 # ideally don't much more than this many matches - never_accept_this = 4 # values this edit distance or over always rejected + never_accept_this = 5 # values this edit distance or over always rejected # first, find possible matches quickly q_grams = self.text_to_positional_q_gram(word) @@ -61,6 +61,7 @@ def auto_complete(self, word: str, database) -> list: matches = [] first_matches = Counter() other_matches = {} + probably_keys = [] # if we suspect it's a key hash, dump it at the end of the list # now, refine with edit distance for row in possible_matches.itertuples(): @@ -68,8 +69,11 @@ def auto_complete(self, word: str, database) -> list: continue test_word = row[1][:len(word)] # only find edit distance of first part of word - edit_distance = self.osa_distance(word, test_word, cutoff=min(never_accept_this, len(word))) - if edit_distance == 0: + edit_distance = self.osa_distance(word, test_word, cutoff=min(never_accept_this, (len(word) * (2 / 3)))) + if len(row[1]) == 32 and edit_distance < never_accept_this: + # dump any items that are likely to be keys at the end of the list + probably_keys.append(row[1]) + elif edit_distance == 0: first_matches[row[1]] = count_occurence(row[1]) elif edit_distance < never_accept_this: if not other_matches.get(edit_distance): @@ -96,6 +100,7 @@ def auto_complete(self, word: str, database) -> list: break prev_num = num + matches = matches + probably_keys return matches def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: diff --git a/activity_browser/ui/widgets/line_edit.py b/activity_browser/ui/widgets/line_edit.py index 244fa59c5..9545c5943 100644 --- a/activity_browser/ui/widgets/line_edit.py +++ b/activity_browser/ui/widgets/line_edit.py @@ -173,5 +173,9 @@ def _set_items(self, text=None): self.model.setStringList(items) # set correct height now that we have data - self.popup.setMaximumHeight(self.popup.sizeHintForRow(0) * 3 + 2 * self.popup.frameWidth()) + max_height = max( + 20, + self.popup.sizeHintForRow(0) * 3 + 2 * self.popup.frameWidth() + ) + self.popup.setMaximumHeight(max_height) From bba71c7c0075870ff86be321dc1de32a01fa721c Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Fri, 5 Sep 2025 17:59:27 +0200 Subject: [PATCH 027/267] better key hash sorting --- activity_browser/bwutils/searchengine/base.py | 4 +--- .../bwutils/searchengine/metadata_search.py | 19 +++++++++---------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index 1cc8235ee..fcee9e4cd 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -524,7 +524,6 @@ def spell_check(self, text: str) -> OrderedDict: q_grams = self.text_to_positional_q_gram(word) possible_matches = self.find_q_gram_matches(set(q_grams)) - matches = [] first_matches = Counter() other_matches = {} @@ -545,8 +544,7 @@ def spell_check(self, text: str) -> OrderedDict: continue # add matches in correct order: - for match, _ in first_matches.most_common(): - matches.append(match) + matches = [match for match, _ in first_matches.most_common()] # if we have fewer matches than goal, add more 'less good' matches if len(matches) < matches_min: for i in range(always_accept_this + 1, never_accept_this): diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py index 33bdc34e8..43ae1d051 100644 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -40,7 +40,7 @@ def change_identifier(self, identifier, data: pd.DataFrame) -> None: super().change_identifier(identifier, data) self.reset_database_id_manager() - def auto_complete(self, word: str, database) -> list: + def auto_complete(self, word: str, database: Optional[str] = None) -> list: """Based on spellchecker, make more useful for autocompletions """ if len(word) <= 1: @@ -53,15 +53,16 @@ def auto_complete(self, word: str, database) -> list: matches_min = 2 # ideally we have at least this many alternatives matches_max = 4 # ideally don't much more than this many matches never_accept_this = 5 # values this edit distance or over always rejected + # or max 2/3 of len(word) if less than never_accept_this + never_accept_this = int(round(min(never_accept_this, max(1, len(word) * (2 / 3))), 0)) # first, find possible matches quickly q_grams = self.text_to_positional_q_gram(word) possible_matches = self.find_q_gram_matches(set(q_grams)) - matches = [] first_matches = Counter() other_matches = {} - probably_keys = [] # if we suspect it's a key hash, dump it at the end of the list + probably_keys = Counter() # if we suspect it's a key hash, dump it at the end of the list # now, refine with edit distance for row in possible_matches.itertuples(): @@ -69,10 +70,9 @@ def auto_complete(self, word: str, database) -> list: continue test_word = row[1][:len(word)] # only find edit distance of first part of word - edit_distance = self.osa_distance(word, test_word, cutoff=min(never_accept_this, (len(word) * (2 / 3)))) - if len(row[1]) == 32 and edit_distance < never_accept_this: - # dump any items that are likely to be keys at the end of the list - probably_keys.append(row[1]) + edit_distance = self.osa_distance(word, test_word, cutoff=never_accept_this) + if len(row[1]) == 32 and edit_distance <= 1: + probably_keys[row[1]] = 100 - edit_distance # keys need to be sorted on edit distance, not on occurence elif edit_distance == 0: first_matches[row[1]] = count_occurence(row[1]) elif edit_distance < never_accept_this: @@ -83,8 +83,7 @@ def auto_complete(self, word: str, database) -> list: continue # add matches in correct order: - for match, _ in first_matches.most_common(): - matches.append(match) + matches = [match for match, _ in first_matches.most_common()] # if we have fewer matches than goal, add more 'less good' matches if len(matches) < matches_min: for i in range(1, never_accept_this): @@ -100,7 +99,7 @@ def auto_complete(self, word: str, database) -> list: break prev_num = num - matches = matches + probably_keys + matches = matches + [match for match, _ in probably_keys.most_common()] return matches def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: From 8e734369b34ddeecd2731f60dd5c01e9ac107eb9 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Fri, 5 Sep 2025 19:21:58 +0200 Subject: [PATCH 028/267] better autocomplete performance when many long qgram matches --- .../bwutils/searchengine/metadata_search.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py index 43ae1d051..4c776235d 100644 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -43,13 +43,12 @@ def change_identifier(self, identifier, data: pd.DataFrame) -> None: def auto_complete(self, word: str, database: Optional[str] = None) -> list: """Based on spellchecker, make more useful for autocompletions """ + count_occurence = lambda x: sum(self.word_to_identifier[x].values()) # count occurences of a word if len(word) <= 1: return [] self.database_id_manager(database) - count_occurence = lambda x: sum(self.word_to_identifier[x].values()) # count occurences of a word - matches_min = 2 # ideally we have at least this many alternatives matches_max = 4 # ideally don't much more than this many matches never_accept_this = 5 # values this edit distance or over always rejected @@ -58,7 +57,7 @@ def auto_complete(self, word: str, database: Optional[str] = None) -> list: # first, find possible matches quickly q_grams = self.text_to_positional_q_gram(word) - possible_matches = self.find_q_gram_matches(set(q_grams)) + possible_matches = self.find_q_gram_matches(set(q_grams), return_all=True) first_matches = Counter() other_matches = {} @@ -68,9 +67,8 @@ def auto_complete(self, word: str, database: Optional[str] = None) -> list: for row in possible_matches.itertuples(): if len(word) > len(row[1]) or word == row[1]: continue - test_word = row[1][:len(word)] # only find edit distance of first part of word - - edit_distance = self.osa_distance(word, test_word, cutoff=never_accept_this) + # find edit distance of same size strings + edit_distance = self.osa_distance(word, row[1][:len(word)], cutoff=never_accept_this) if len(row[1]) == 32 and edit_distance <= 1: probably_keys[row[1]] = 100 - edit_distance # keys need to be sorted on edit distance, not on occurence elif edit_distance == 0: @@ -102,7 +100,7 @@ def auto_complete(self, word: str, database: Optional[str] = None) -> list: matches = matches + [match for match, _ in probably_keys.most_common()] return matches - def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: + def find_q_gram_matches(self, q_grams: set, return_all: bool = False) -> pd.DataFrame: """Overwritten for extra database specific reduction of results. """ n_q_grams = len(q_grams) @@ -137,10 +135,13 @@ def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: max_q = max(matches["matches"]) # this has the most matching q-grams # determine how many results we want to keep based on how good our results are - min_q = min(max(max_q * 0.32, # have at least a third of q-grams of best match or... - max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? - 1)), # okay just do 1 q-gram if there are no more in the word - max_q) # never have min_q be over max_q + if not return_all: + min_q = min(max(max_q * 0.32, # have at least a third of q-grams of best match or... + max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? + 1)), # okay just do 1 q-gram if there are no more in the word + max_q) # never have min_q be over max_q + else: + min_q = 0 matches = matches[matches["matches"] >= min_q] matches = matches.sort_values(by="matches", ascending=False) From 2f078595a16278dd7390b0c3679a4db519b32e43 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Fri, 5 Sep 2025 21:46:24 +0200 Subject: [PATCH 029/267] resolve bug with removing identifier from searchengine leading to breaking search --- activity_browser/bwutils/searchengine/base.py | 8 ++++++-- tests/test_search.py | 10 ++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index fcee9e4cd..635d58b20 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -292,7 +292,6 @@ def remove_identifier(self, identifier, logging=True) -> None: raise Exception( f"Identifier '{identifier}' does not exist in the search data, cannot remove identifier that do not exist.") - # remove from df self.df = self.df.drop(identifier) # find words that may need to be removed @@ -309,10 +308,15 @@ def remove_identifier(self, identifier, logging=True) -> None: # this q_gram is only used in this word, # remove it del self.q_gram_to_word[q_gram] + elif len(self.q_gram_to_word[q_gram]) > 1: + # this q_gram is used in multiple words, only remove the word from the q_gram + del self.q_gram_to_word[q_gram][word] del self.word_to_q_grams[word] else: - # remove the identifier from the dict + # this word is found in multiple identifiers + # word_to_q_gram and q_gram_to_word do not need to be changed, the word still exists + # remove the identifier the word in word_to_identifier del self.word_to_identifier[word][identifier] # finally, remove the identifier del self.identifier_to_word[identifier] diff --git a/tests/test_search.py b/tests/test_search.py index 6d63c14ee..727870359 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -9,8 +9,8 @@ def data_for_test(): ["b", "coal production", "something"], ["c", "coal production", "coat"], ["d", "coal hello production", "something"], - ["e", "dont find me", "hello world"], - ["f", "coat", "another word"], + ["e", "dont zzfind me", "hello world"], + ["f", "coat", "zzanother word"], ["g", "coalispartofthisword", "things"], ["h", "coal", "coal"], ], @@ -199,6 +199,12 @@ def test_search_remove_identifier(): se.remove_identifier(identifier="a") assert se.search("coal production") == ["c", "b", "d", "h", "f", "g"] + # now search on something only in a column we later remove + assert se.search("find") == ["e"] + se.remove_identifier(identifier="e") + assert se.search("find") == [] + + def test_search_change_identifier(): """Do tests for changing identifier.""" From 6e5d1cbb9813bf9b501865d32648979599edcde2 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Fri, 5 Sep 2025 22:47:18 +0200 Subject: [PATCH 030/267] add functionality for adding, changing and removing identifiers (except full databases) --- activity_browser/bwutils/metadata.py | 49 ++++++++++++++++--- activity_browser/bwutils/searchengine/base.py | 2 +- .../bwutils/searchengine/metadata_search.py | 25 ++++++++-- tests/test_search.py | 1 - 4 files changed, 64 insertions(+), 13 deletions(-) diff --git a/activity_browser/bwutils/metadata.py b/activity_browser/bwutils/metadata.py index 70ab0606f..04498e991 100644 --- a/activity_browser/bwutils/metadata.py +++ b/activity_browser/bwutils/metadata.py @@ -67,6 +67,12 @@ def __init__(self, parent=None): self.moveToThread(application.thread()) self.connect_signals() + self.search_engine_whitelist = [ + "id", "name", "synonyms", "unit", "key", "database", # generic + "CAS number", "categories", # biosphere specific + "product", "reference product", "classifications", "location", "properties" # activity specific + ] + def connect_signals(self): signals.project.changed.connect(self.sync) signals.node.changed.connect(self.on_node_changed) @@ -76,11 +82,29 @@ def connect_signals(self): def on_node_deleted(self, ds): try: - self.dataframe.drop(ds.key, inplace=True) + self.dataframe = self.dataframe.drop(ds.key) + self.remove_identifier_from_search_engine(ds) self.synced.emit() except KeyError: pass + def remove_identifier_from_search_engine(self, ds, reset_db_ids=True, logging=True): + data = model_to_dict(ds) + identifier = data["id"] + if identifier in self.search_engine.database_id_manager(data["database"]): + self.search_engine.remove_identifier(identifier, logging=logging) + if reset_db_ids: + self.search_engine.reset_database_id_manager() + + def remove_identifiers_from_search_engine(self, identifiers): + t = time() + for identifier in identifiers: + self.remove_identifier_from_search_engine(identifier, reset_db_ids=False, logging=False) + self.search_engine.reset_database_id_manager() + log.debug(f"Search index updated in {time() - t:.2f} seconds " + f"for {len(identifiers)} removed items " + f"({len(self.search_engine.df)} items ({self.search_engine.size_of_index()}) currently).") + def on_node_changed(self, new, old): data_raw = model_to_dict(new) data = data_raw.pop("data") @@ -98,13 +122,28 @@ def on_node_changed(self, new, old): for col in [col for col in data.columns if col not in self.dataframe.columns]: self.dataframe[col] = pd.NA self.dataframe.loc[new.key] = data.loc[new.key] + self.change_identifier_in_search_engine(identifier=data.loc[new.key, "id"], data=data.loc[[new.key]]) elif self.dataframe.empty: # an activity has been added and the dataframe was empty self.dataframe = data + self.add_identifier_to_search_engine(data) else: # an activity has been added and needs to be concatenated to existing metadata self.dataframe = pd.concat([self.dataframe, data], join="outer") + self.add_identifier_to_search_engine(data) self.thread().eventDispatcher().awake.connect(self._emitSyncLater, Qt.ConnectionType.UniqueConnection) + def add_identifier_to_search_engine(self, data: pd.DataFrame): + search_engine_cols = list(set(data.columns) & set(self.search_engine_whitelist)) # intersection becomes columns + data = data[search_engine_cols] + self.search_engine.add_identifier(data.copy()) + self.search_engine.reset_database_id_manager() + + def change_identifier_in_search_engine(self, identifier, data: pd.DataFrame): + search_engine_cols = list(set(data.columns) & set(self.search_engine_whitelist)) # intersection becomes columns + data = data[search_engine_cols] + self.search_engine.change_identifier(identifier=identifier, data=data.copy()) + self.search_engine.reset_database_id_manager() + @property def databases(self): return set(self.dataframe.get("database", [])) @@ -354,13 +393,9 @@ def _unpacker(self, classifications: list, system: str) -> list: return system_classifications def init_search(self): - allowed_cols = [ - "id", "name", "synonyms", "unit", "key", "database", # generic - "CAS number", "categories", # biosphere specific - "product", "reference product", "classifications", "location", "properties" # activity specific - ] - self.search_engine = MetaDataSearchEngine(self.dataframe, identifier_name="id", searchable_columns=allowed_cols) + + self.search_engine = MetaDataSearchEngine(self.dataframe, identifier_name="id", searchable_columns=self.search_engine_whitelist) def db_search(self, query:str, database: Optional[str] = None, return_counter: bool = False): return self.search_engine.fuzzy_search(query, database=database, return_counter=return_counter) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index 635d58b20..f0f34261b 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -262,7 +262,7 @@ def add_identifier(self, data: pd.DataFrame) -> None: # add cols to new data that are missing for col in df_cols: if col not in data.columns: - data[col] = [""] * len(data) + data.loc[:, col] = [""] * len(data) # re-order cols, first existing, then new df_col_set = set(df_cols) new_cols = [col for col in data.columns if col not in self.columns if col not in df_col_set] diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py index 4c776235d..3e70a3cfd 100644 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -23,17 +23,34 @@ def database_id_manager(self, database): self.all_database_ids[database] = self.database_ids else: self.database_ids = None + return self.database_ids def reset_database_id_manager(self): - del self.all_database_ids - del self.database_ids + if hasattr(self, "all_database_ids"): + del self.all_database_ids + if hasattr(self, "database_ids"): + del self.database_ids def add_identifier(self, data: pd.DataFrame) -> None: super().add_identifier(data) self.reset_database_id_manager() - def remove_identifier(self, identifier, logging=True) -> None: - super().remove_identifier(identifier, logging=logging) + + def remove_identifiers(self, identifiers, logging=True) -> None: + t = time() + + identifiers = set(identifiers) + current_identifiers = set(self.df.index.to_list()) + identifiers = identifiers | current_identifiers # only remove identifiers currently in the data + if len(identifiers) == 0: + return + + for identifier in identifiers: + super().remove_identifier(identifier, logging=False) + + if logging: + log.debug(f"Search index updated in {time() - t:.2f} seconds " + f"for {len(identifiers)} removed items ({len(self.df)} items ({self.size_of_index()}) currently).") self.reset_database_id_manager() def change_identifier(self, identifier, data: pd.DataFrame) -> None: diff --git a/tests/test_search.py b/tests/test_search.py index 727870359..0c40f4340 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -205,7 +205,6 @@ def test_search_remove_identifier(): assert se.search("find") == [] - def test_search_change_identifier(): """Do tests for changing identifier.""" df = data_for_test() From 0bd672c66a0f94d11b49ec7456e1846fdacaef2a Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Sat, 6 Sep 2025 16:29:52 +0200 Subject: [PATCH 031/267] add functionality for adding and removing full databases --- activity_browser/bwutils/metadata.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/activity_browser/bwutils/metadata.py b/activity_browser/bwutils/metadata.py index 04498e991..e8a1523ce 100644 --- a/activity_browser/bwutils/metadata.py +++ b/activity_browser/bwutils/metadata.py @@ -88,18 +88,17 @@ def on_node_deleted(self, ds): except KeyError: pass - def remove_identifier_from_search_engine(self, ds, reset_db_ids=True, logging=True): + def remove_identifier_from_search_engine(self, ds): data = model_to_dict(ds) identifier = data["id"] if identifier in self.search_engine.database_id_manager(data["database"]): - self.search_engine.remove_identifier(identifier, logging=logging) - if reset_db_ids: - self.search_engine.reset_database_id_manager() + self.search_engine.remove_identifier(identifier) + self.search_engine.reset_database_id_manager() def remove_identifiers_from_search_engine(self, identifiers): t = time() for identifier in identifiers: - self.remove_identifier_from_search_engine(identifier, reset_db_ids=False, logging=False) + self.search_engine.remove_identifier(identifier, logging=False) self.search_engine.reset_database_id_manager() log.debug(f"Search index updated in {time() - t:.2f} seconds " f"for {len(identifiers)} removed items " @@ -195,7 +194,10 @@ def sync_databases(self) -> None: for db_name in [x for x in self.databases if x not in bd.databases]: # deleted databases + remove_search_engine = self.dataframe[self.dataframe["database"] == db_name]["id"] self.dataframe.drop(db_name, level=0, inplace=True) + if len(remove_search_engine) > 0: + self.remove_identifiers_from_search_engine(remove_search_engine) sync = True for db_name in [x for x in bd.databases if x not in self.databases]: @@ -208,7 +210,7 @@ def sync_databases(self) -> None: self.dataframe = data else: self.dataframe = pd.concat([self.dataframe, data], join="outer") - + self.add_identifier_to_search_engine(data) sync = True if sync: From 4791c5647addbc212850a1c5346031fd7dd1eaca Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Sun, 7 Sep 2025 09:39:48 +0200 Subject: [PATCH 032/267] improve matching speed after metadata conversion to ProductModel --- activity_browser/bwutils/searchengine/base.py | 15 +++++++++++---- .../layouts/panes/database_products.py | 17 ++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index f0f34261b..4bcc3d45d 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -488,7 +488,7 @@ def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: return matches.iloc[:min(len(matches), 2500), :] # return at most this many results - def spell_check(self, text: str) -> OrderedDict: + def spell_check(self, text: str, skip_len=1) -> OrderedDict: """Create an OrderedDict of each word in the text (space separated) with as values possible alternatives. @@ -524,6 +524,13 @@ def spell_check(self, text: str) -> OrderedDict: words = [self.clean_text(word) for word in words] for word in words: + if len(word) <= skip_len: # dont look for alternatives for text this short + word_results[word] = [] + continue + + # reduce acceptable edit distance with short words + dont_accept = int(round(max(1, min((len(word) * 0.66), never_accept_this)), 0)) + # first, find possible matches quickly q_grams = self.text_to_positional_q_gram(word) possible_matches = self.find_q_gram_matches(set(q_grams)) @@ -534,13 +541,13 @@ def spell_check(self, text: str) -> OrderedDict: # now, refine with edit distance for row in possible_matches.itertuples(): - edit_distance = self.osa_distance(word, row[1], cutoff=never_accept_this) + edit_distance = self.osa_distance(word, row[1], cutoff=dont_accept) if edit_distance == 0: continue # we are looking for alternatives only, not the exact word elif edit_distance <= always_accept_this: first_matches[row[1]] = count_occurence(row[1]) - elif edit_distance < never_accept_this: + elif edit_distance < dont_accept: if not other_matches.get(edit_distance): other_matches[edit_distance] = Counter() other_matches[edit_distance][row[1]] = count_occurence(row[1]) @@ -551,7 +558,7 @@ def spell_check(self, text: str) -> OrderedDict: matches = [match for match, _ in first_matches.most_common()] # if we have fewer matches than goal, add more 'less good' matches if len(matches) < matches_min: - for i in range(always_accept_this + 1, never_accept_this): + for i in range(always_accept_this + 1, dont_accept): # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives if new := other_matches.get(i): prev_num = 10e100 diff --git a/activity_browser/layouts/panes/database_products.py b/activity_browser/layouts/panes/database_products.py index 48de92aa1..680f4d2eb 100644 --- a/activity_browser/layouts/panes/database_products.py +++ b/activity_browser/layouts/panes/database_products.py @@ -493,19 +493,18 @@ def external_search(self, query): df = AB_metadata.dataframe[AB_metadata.dataframe["id"].isin(result_ids)].loc[:, ["id", "key"]] df = df.set_index("key", drop=True) translate_dict = df.to_dict()["id"] + result_keys = set(translate_dict.keys()) # convert the metadata id scores to row id scores row_scores = Counter() - df = self.dataframe.copy() - act_idx = set(df[df["activity_key"].isin(translate_dict.keys())].index.to_list()) - prd_idx = set(df[df["product_key"].isin(translate_dict.keys())].index.to_list()) - indices = act_idx | prd_idx # combine the two sets ('|' is a set union) - # iterate over the indices - for index in indices: - act_score = results.get(translate_dict.get(df.loc[index, "activity_key"]), 0) - prd_score = results.get(translate_dict.get(df.loc[index, "product_key"]), 0) - row_scores[index] = act_score + prd_score + match_df = self.dataframe[self.dataframe["activity_key"].isin(result_keys) | self.dataframe["product_key"].isin(result_keys)] + match_df = match_df.loc[:, ["activity_key", "product_key"]] + for row in match_df.itertuples(): + act_score = results.get(row[1], 0) + prd_score = results.get(row[2], 0) + row_scores[row[0]] = act_score + prd_score # finally only return the indices sorted_indices = [identifier[0] for identifier in row_scores.most_common()] + return sorted_indices From 532cac268a40e7d66f5ad2e11e7114424a99598c Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Sun, 7 Sep 2025 18:42:12 +0200 Subject: [PATCH 033/267] make autocomplete suggestions aware of context of other words in query, improving usefulness --- activity_browser/bwutils/searchengine/base.py | 2 +- .../bwutils/searchengine/metadata_search.py | 37 +++++++++++++++---- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index 4bcc3d45d..ca36e452a 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -753,7 +753,7 @@ def fuzzy_search(self, text: str, return_counter: bool = False) -> list: if return_counter: return all_identifiers # now sort on highest weights and make list type - sorted_identifiers = [identifier[0] for identifier in all_identifiers.most_common()] + sorted_identifiers = [identifier for identifier, _ in all_identifiers.most_common()] return sorted_identifiers def search(self, text) -> list: diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py index 3e70a3cfd..8e55d27cc 100644 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -57,20 +57,43 @@ def change_identifier(self, identifier, data: pd.DataFrame) -> None: super().change_identifier(identifier, data) self.reset_database_id_manager() - def auto_complete(self, word: str, database: Optional[str] = None) -> list: + def auto_complete(self, word: str, context: Optional[set] = set(), database: Optional[str] = None) -> list: """Based on spellchecker, make more useful for autocompletions """ - count_occurence = lambda x: sum(self.word_to_identifier[x].values()) # count occurences of a word + def word_to_identifier_to_word(check_word): + # assumes context words are correctly spelled + if len(context) == 0: + return 1 + multiplier = 1 + for identifier in self.word_to_identifier[check_word]: + for context_word in context: + for spell_checked_context_word in spell_checked_context[context_word]: + if spell_checked_context_word in self.identifier_to_word[identifier]: + multiplier += 1 + if context_word not in self.word_to_identifier.keys(): + continue + if context_word in self.identifier_to_word[identifier]: + multiplier += 3 + return multiplier + + # count occurrences of a word, count double so word_to_identifier_to_word will never multiply by 1 + count_occurrence = lambda x: sum(self.word_to_identifier[x].values()) * 2 + if len(word) <= 1: return [] self.database_id_manager(database) + if len(context) > 0: + spell_checked_context = {} + for context_word in context: + spell_checked_context[context_word] = self.spell_check(context_word)[context_word][:5] + matches_min = 2 # ideally we have at least this many alternatives matches_max = 4 # ideally don't much more than this many matches - never_accept_this = 5 # values this edit distance or over always rejected + never_accept_this = 4 # values this edit distance or over always rejected # or max 2/3 of len(word) if less than never_accept_this - never_accept_this = int(round(min(never_accept_this, max(1, len(word) * (2 / 3))), 0)) + never_accept_this = int(round(max(1, min((len(word) * 0.66), never_accept_this)), 0)) # first, find possible matches quickly q_grams = self.text_to_positional_q_gram(word) @@ -89,11 +112,11 @@ def auto_complete(self, word: str, database: Optional[str] = None) -> list: if len(row[1]) == 32 and edit_distance <= 1: probably_keys[row[1]] = 100 - edit_distance # keys need to be sorted on edit distance, not on occurence elif edit_distance == 0: - first_matches[row[1]] = count_occurence(row[1]) - elif edit_distance < never_accept_this: + first_matches[row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) + elif edit_distance < never_accept_this and len(first_matches) < matches_min: if not other_matches.get(edit_distance): other_matches[edit_distance] = Counter() - other_matches[edit_distance][row[1]] = count_occurence(row[1]) + other_matches[edit_distance][row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) else: continue From 42c359306a7251d4fc706fff112c75f26feb0844 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Sun, 7 Sep 2025 18:56:41 +0200 Subject: [PATCH 034/267] ProductModel suggestions now include literal matches better --- activity_browser/bwutils/metadata.py | 12 +++---- .../bwutils/searchengine/metadata_search.py | 2 +- .../layouts/panes/database_products.py | 36 ++++++++++++++----- activity_browser/ui/widgets/line_edit.py | 3 +- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/activity_browser/bwutils/metadata.py b/activity_browser/bwutils/metadata.py index e8a1523ce..32afb629b 100644 --- a/activity_browser/bwutils/metadata.py +++ b/activity_browser/bwutils/metadata.py @@ -395,19 +395,19 @@ def _unpacker(self, classifications: list, system: str) -> list: return system_classifications def init_search(self): - - self.search_engine = MetaDataSearchEngine(self.dataframe, identifier_name="id", searchable_columns=self.search_engine_whitelist) - def db_search(self, query:str, database: Optional[str] = None, return_counter: bool = False): - return self.search_engine.fuzzy_search(query, database=database, return_counter=return_counter) + def db_search(self, query:str, database: Optional[str] = None, return_counter: bool = False, logging: bool = True): + # we do fuzzy search as we re-index results (combining products and activities) for database_products table + # anyway, so including literal results quite literally is a waste of time at this point + return self.search_engine.fuzzy_search(query, database=database, return_counter=return_counter, logging=logging) def search(self, query:str): return self.search_engine.search(query) - def auto_complete(self, word:str, database: Optional[str] = None): + def auto_complete(self, word:str, context: Optional[set] = None, database: Optional[str] = None): word = self.search_engine.clean_text(word) - completions = self.search_engine.auto_complete(word, database) + completions = self.search_engine.auto_complete(word, context=context, database=database) return completions AB_metadata = MetaDataStore() diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py index 8e55d27cc..ff580b88b 100644 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -87,7 +87,7 @@ def word_to_identifier_to_word(check_word): if len(context) > 0: spell_checked_context = {} for context_word in context: - spell_checked_context[context_word] = self.spell_check(context_word)[context_word][:5] + spell_checked_context[context_word] = self.spell_check(context_word).get(context_word, [])[:5] matches_min = 2 # ideally we have at least this many alternatives matches_max = 4 # ideally don't much more than this many matches diff --git a/activity_browser/layouts/panes/database_products.py b/activity_browser/layouts/panes/database_products.py index 680f4d2eb..caf83aace 100644 --- a/activity_browser/layouts/panes/database_products.py +++ b/activity_browser/layouts/panes/database_products.py @@ -485,7 +485,9 @@ def values_from_indices(key: str, indices: list[QtCore.QModelIndex]): return values def external_search(self, query): - results = AB_metadata.db_search(query, database=self.external_col_name, return_counter=True) + t = time() + results = AB_metadata.db_search(query, database=self.external_col_name, return_counter=True, logging=False) + t2 = time() # extract a dict with 'key' as key and 'id' as values from the metadata result_ids = set(results.keys()) @@ -496,15 +498,33 @@ def external_search(self, query): result_keys = set(translate_dict.keys()) # convert the metadata id scores to row id scores - row_scores = Counter() + best_row_scores = Counter() + remain_row_scores = Counter() match_df = self.dataframe[self.dataframe["activity_key"].isin(result_keys) | self.dataframe["product_key"].isin(result_keys)] - match_df = match_df.loc[:, ["activity_key", "product_key"]] + cols = ["activity_key", "product_key"] + cols = cols + [col for col in match_df.columns if col not in cols] + match_df = match_df.loc[:, cols] for row in match_df.itertuples(): - act_score = results.get(row[1], 0) - prd_score = results.get(row[2], 0) - row_scores[row[0]] = act_score + prd_score + # score higher if exact words occur + act_score = results.get(translate_dict.get(row[1]), 0) + prd_score = results.get(translate_dict.get(row[2]), 0) + row_text = str(row[1:]) + for query_word in query.split(" "): + if amt := query.count(query_word) > 0 and len(query_word) > 0: + best_row_scores[row[0]] = (act_score + prd_score) * amt + if query in row_text: + score = (best_row_scores.get(row[0], 0) + act_score + prd_score) * 2 + best_row_scores[row[0]] = score + else: + remain_row_scores[row[0]] = act_score + prd_score # finally only return the indices - sorted_indices = [identifier[0] for identifier in row_scores.most_common()] - + best_sorted_indices = [identifier for identifier, _ in best_row_scores.most_common()] + remain_sorted_indices = [identifier for identifier, _ in remain_row_scores.most_common()] + sorted_indices = best_sorted_indices + remain_sorted_indices + log.debug( + f"ProductModel search in '{self.external_col_name}' ({len(self.dataframe)} items) " + f"found {len(sorted_indices)} ({len(best_sorted_indices)} literal) results " + f"for '{query}' in {time() - t:.2f} seconds ({t2 - t:.2f}s actual search, {time() - t2:.2f}s reorder for table)" + ) return sorted_indices diff --git a/activity_browser/ui/widgets/line_edit.py b/activity_browser/ui/widgets/line_edit.py index 9545c5943..356b5707e 100644 --- a/activity_browser/ui/widgets/line_edit.py +++ b/activity_browser/ui/widgets/line_edit.py @@ -160,9 +160,10 @@ def _set_items(self, text=None): if not current_word: self.model.setStringList([]) return + context = set((text[:start] + text[end:]).split(" ")) # get suggestions for the current word - alternatives = AB_metadata.auto_complete(current_word, database=self.database_name) + alternatives = AB_metadata.auto_complete(current_word, context=context, database=self.database_name) alternatives = alternatives[:6] # at most 6, though we should get ~3 usually # replace the current word with each alternative items = [] From 4ec98fb890ce9d484264c0014db05977efb4afae Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Mon, 8 Sep 2025 11:56:04 +0200 Subject: [PATCH 035/267] Update line-edit autocompleter base class --- activity_browser/ui/widgets/line_edit.py | 118 +++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/activity_browser/ui/widgets/line_edit.py b/activity_browser/ui/widgets/line_edit.py index 356b5707e..11fd8b793 100644 --- a/activity_browser/ui/widgets/line_edit.py +++ b/activity_browser/ui/widgets/line_edit.py @@ -180,3 +180,121 @@ def _set_items(self, text=None): ) self.popup.setMaximumHeight(max_height) +class ABTextEdit(QtWidgets.QTextEdit): + textChangedDebounce: SignalInstance = Signal(str) + _debounce_ms = 250 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._debounce_timer = QTimer(self, singleShot=True) + + self.textChanged.connect(self._set_debounce) + self._debounce_timer.timeout.connect(self._emit_debounce) + + def _set_debounce(self): + self._debounce_timer.setInterval(self._debounce_ms) + self._debounce_timer.start() + + def _emit_debounce(self): + self.textChangedDebounce.emit(self.toPlainText()) + + def debounce(self): + return self._debounce_ms + + def setDebounce(self, ms: int): + self._debounce_ms = ms + + +class MetaDataAutoCompleteLineEdit(ABTextEdit): + """Line Edit with MetaDataStore completer attached""" + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.database_name = "" + + # autocompleter settings + self.model = QStringListModel() + self.completer = QCompleter(self.model) + self.completer.setWidget(self) + self.popup = self.completer.popup() + self.popup.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.completer.setPopup(self.popup) + # allow all items in popup list + self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) + self.completer.activated.connect(self._insert_auto_complete) + + self.textChanged.connect(self.sanitize_input) + + def sanitize_input(self): + text = self.toPlainText() + text = AB_metadata.search_engine.ONE_SPACE_PATTERN.sub(" ", text) + self.blockSignals(True) + self.clear() + self.insertPlainText(text) + self.blockSignals(False) + if len(text) == 0: + self.popup.close() + + def _insert_auto_complete(self, completion): + self.clear() + self.insertPlainText(completion) + self.popup.close() + self._set_items() + + def _set_items(self): + text = self.toPlainText() + + # find the start and end of the word under the cursor + cursor_pos = self.textCursor().position() + start = cursor_pos + while start > 0 and text[start - 1] != " ": + start -= 1 + end = cursor_pos + while end < len(text) and text[end] != " ": + end += 1 + current_word = text[start:end] + if not current_word: + self.model.setStringList([]) + return + context = set((text[:start] + text[end:]).split(" ")) + + # get suggestions for the current word + alternatives = AB_metadata.auto_complete(current_word, context=context, database=self.database_name) + alternatives = alternatives[:6] # at most 6, though we should get ~3 usually + # replace the current word with each alternative + items = [] + for alt in alternatives: + new_text = text[:start] + alt + text[end:] + items.append(new_text) + print(text, items) + if len(items) == 0: + return + + self.model.setStringList(items) + # set correct height now that we have data + max_height = max( + 20, + self.popup.sizeHintForRow(0) * 3 + 2 * self.popup.frameWidth() + ) + self.popup.setMaximumHeight(max_height) + self.completer.complete() + + def keyPressEvent(self, event): + key = event.key() + + if key in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Tab): + # insert an autocomplete item + # capture enter/return/tab key + index = self.popup.currentIndex() + selected_text = index.data(Qt.DisplayRole) + self.completer.activated.emit(selected_text + " ") + return + elif key in (Qt.Key_Space,): + self.popup.close() + + super().keyPressEvent(event) + + # trigger on text input keys + if event.text(): # filters out non-text keys like arrows, shift, etc. + self._set_items() From 72e01d1850d446aa879b5f29a062e86b36ed5bbe Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Tue, 9 Sep 2025 10:25:10 +0200 Subject: [PATCH 036/267] Add marking of unknown words to search --- activity_browser/ui/widgets/line_edit.py | 99 ++++++++++++++++++++---- 1 file changed, 83 insertions(+), 16 deletions(-) diff --git a/activity_browser/ui/widgets/line_edit.py b/activity_browser/ui/widgets/line_edit.py index 11fd8b793..7095a5f88 100644 --- a/activity_browser/ui/widgets/line_edit.py +++ b/activity_browser/ui/widgets/line_edit.py @@ -1,6 +1,6 @@ from qtpy import QtWidgets from qtpy.QtCore import QTimer, Slot, Signal, SignalInstance, QStringListModel, Qt -from qtpy.QtGui import QTextFormat +from qtpy.QtGui import QTextFormat, QSyntaxHighlighter, QTextCharFormat, QTextDocument, QTextCursor from qtpy.QtWidgets import QCompleter from activity_browser.bwutils import AB_metadata @@ -180,6 +180,29 @@ def _set_items(self, text=None): ) self.popup.setMaximumHeight(max_height) + +class UnknownWordHighlighter(QSyntaxHighlighter): + def __init__(self, parent: QTextDocument, known_words: set): + super().__init__(parent) + self.known_words = known_words + + # define the format for unknown words + self.unknown_format = QTextCharFormat() + self.unknown_format.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) + self.unknown_format.setUnderlineColor(Qt.red) + + def highlightBlock(self, text: str): + if text.startswith("="): + return + words = text.split() + index = 0 + for word in words: + word_len = len(word) + if word and word not in self.known_words: + self.setFormat(index, word_len, self.unknown_format) + index += word_len + 1 # +1 for the space + + class ABTextEdit(QtWidgets.QTextEdit): textChangedDebounce: SignalInstance = Signal(str) _debounce_ms = 250 @@ -212,6 +235,7 @@ class MetaDataAutoCompleteLineEdit(ABTextEdit): def __init__(self, parent=None): super().__init__(parent=parent) self.database_name = "" + self.auto_complete_word = "" # autocompleter settings self.model = QStringListModel() @@ -225,27 +249,64 @@ def __init__(self, parent=None): self.completer.activated.connect(self._insert_auto_complete) self.textChanged.connect(self.sanitize_input) + self.highlighter = UnknownWordHighlighter(self.document(), set()) + self.cursorPositionChanged.connect(self._set_items) def sanitize_input(self): + self._debounce_timer.stop() text = self.toPlainText() - text = AB_metadata.search_engine.ONE_SPACE_PATTERN.sub(" ", text) - self.blockSignals(True) - self.clear() - self.insertPlainText(text) - self.blockSignals(False) + clean_text = AB_metadata.search_engine.ONE_SPACE_PATTERN.sub(" ", text) + + if clean_text != text: + cursor = self.textCursor() + position = cursor.position() + self.blockSignals(True) + self.clear() + self.insertPlainText(clean_text) + self.blockSignals(False) + cursor.setPosition(min(position, len(text))) + self.setTextCursor(cursor) + + known_words = set() + for identifier in AB_metadata.search_engine.database_id_manager(self.database_name): + known_words.update(AB_metadata.search_engine.identifier_to_word[identifier].keys()) + self.highlighter.known_words = known_words + if len(text) == 0: self.popup.close() + self._set_debounce() def _insert_auto_complete(self, completion): - self.clear() - self.insertPlainText(completion) + cursor = self.textCursor() + position = cursor.position() + text = self.toPlainText() + + start = position + while start > 0 and text[start - 1] != " ": + start -= 1 + new_position = start + len(completion) + 1 + + # select the word under the cursor + cursor.select(QTextCursor.WordUnderCursor) + # replace it with the completion + cursor.insertText(completion + " ") + # set the updated cursor to end of inserted word + space + cursor.setPosition(min(new_position, len(text[:start] + completion) + 1)) + self.setTextCursor(cursor) + self.popup.close() - self._set_items() + self.auto_complete_word = "" + self.model.setStringList([]) def _set_items(self): text = self.toPlainText() + if text.startswith("="): + self.model.setStringList([]) + self.auto_complete_word = "" + self.popup.close() + return - # find the start and end of the word under the cursor + # find the start and end of the word under the cursor cursor_pos = self.textCursor().position() start = cursor_pos while start > 0 and text[start - 1] != " ": @@ -257,8 +318,12 @@ def _set_items(self): if not current_word: self.model.setStringList([]) return - context = set((text[:start] + text[end:]).split(" ")) + if self.auto_complete_word == current_word: + # avoid unnecessary auto_complete calls if the current word didnt change + return + self.auto_complete_word = current_word + context = set((text[:start] + text[end:]).split(" ")) # get suggestions for the current word alternatives = AB_metadata.auto_complete(current_word, context=context, database=self.database_name) alternatives = alternatives[:6] # at most 6, though we should get ~3 usually @@ -266,9 +331,11 @@ def _set_items(self): items = [] for alt in alternatives: new_text = text[:start] + alt + text[end:] - items.append(new_text) - print(text, items) + # items.append(new_text) + items.append(alt) + print(cursor_pos, text, items) if len(items) == 0: + self.popup.close() return self.model.setStringList(items) @@ -287,8 +354,8 @@ def keyPressEvent(self, event): # insert an autocomplete item # capture enter/return/tab key index = self.popup.currentIndex() - selected_text = index.data(Qt.DisplayRole) - self.completer.activated.emit(selected_text + " ") + completion_text = index.data(Qt.DisplayRole) + self.completer.activated.emit(completion_text) return elif key in (Qt.Key_Space,): self.popup.close() @@ -296,5 +363,5 @@ def keyPressEvent(self, event): super().keyPressEvent(event) # trigger on text input keys - if event.text(): # filters out non-text keys like arrows, shift, etc. + if event.text() or key in (Qt.LeftArrow, Qt.RightArrow): # filters out non-text keys like arrows, shift, etc. self._set_items() From fbeb4554bd7f304be61ebb50d21671d648497687 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Tue, 9 Sep 2025 10:25:37 +0200 Subject: [PATCH 037/267] drop literal search results --- activity_browser/bwutils/searchengine/base.py | 13 ++++++------ .../bwutils/searchengine/metadata_search.py | 12 +++++------ .../layouts/panes/database_products.py | 21 ++++--------------- 3 files changed, 15 insertions(+), 31 deletions(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index ca36e452a..3d3ffe18c 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -516,13 +516,13 @@ def spell_check(self, text: str, skip_len=1) -> OrderedDict: never_accept_this = 4 # values this edit distance or over always rejected # make list of unique words + text = self.clean_text(text) words = OrderedDict() for word in text.split(" "): - words[word] = False + if len(word) != 0: + words[word] = False words = words.keys() - words = [self.clean_text(word) for word in words] - for word in words: if len(word) <= skip_len: # dont look for alternatives for text this short word_results[word] = [] @@ -703,10 +703,9 @@ def fuzzy_search(self, text: str, return_counter: bool = False) -> list: # finally, make this a Counter (with each item=1) so we can properly weigh things later query_id_sets = [set(query_to_identifier.get(q_word)) for q_word in query if query_to_identifier.get(q_word, False)] - if len(query_id_sets) > 0: - query_identifier_set = set.intersection(*query_id_sets) - else: - query_identifier_set = set() + if len(query_id_sets) == 0: + continue + query_identifier_set = set.intersection(*query_id_sets) if len(query_identifier_set) == 0: # there is no match for this combination of query words, skip break diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py index ff580b88b..374ca56e0 100644 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -61,7 +61,6 @@ def auto_complete(self, word: str, context: Optional[set] = set(), database: Opt """Based on spellchecker, make more useful for autocompletions """ def word_to_identifier_to_word(check_word): - # assumes context words are correctly spelled if len(context) == 0: return 1 multiplier = 1 @@ -73,7 +72,7 @@ def word_to_identifier_to_word(check_word): if context_word not in self.word_to_identifier.keys(): continue if context_word in self.identifier_to_word[identifier]: - multiplier += 3 + multiplier += 4 return multiplier # count occurrences of a word, count double so word_to_identifier_to_word will never multiply by 1 @@ -105,7 +104,7 @@ def word_to_identifier_to_word(check_word): # now, refine with edit distance for row in possible_matches.itertuples(): - if len(word) > len(row[1]) or word == row[1]: + if word == row[1]: continue # find edit distance of same size strings edit_distance = self.osa_distance(word, row[1][:len(word)], cutoff=never_accept_this) @@ -253,10 +252,9 @@ def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter # finally, make this a Counter (with each item=1) so we can properly weigh things later query_id_sets = [set(query_to_identifier.get(q_word)) for q_word in query if query_to_identifier.get(q_word, False)] - if len(query_id_sets) > 0: - query_identifier_set = set.intersection(*query_id_sets) - else: - query_identifier_set = set() + if len(query_id_sets) == 0: + continue + query_identifier_set = set.intersection(*query_id_sets) if len(query_identifier_set) == 0: # there is no match for this combination of query words, skip break diff --git a/activity_browser/layouts/panes/database_products.py b/activity_browser/layouts/panes/database_products.py index caf83aace..86228490a 100644 --- a/activity_browser/layouts/panes/database_products.py +++ b/activity_browser/layouts/panes/database_products.py @@ -498,33 +498,20 @@ def external_search(self, query): result_keys = set(translate_dict.keys()) # convert the metadata id scores to row id scores - best_row_scores = Counter() - remain_row_scores = Counter() + row_scores = Counter() match_df = self.dataframe[self.dataframe["activity_key"].isin(result_keys) | self.dataframe["product_key"].isin(result_keys)] cols = ["activity_key", "product_key"] - cols = cols + [col for col in match_df.columns if col not in cols] match_df = match_df.loc[:, cols] for row in match_df.itertuples(): - # score higher if exact words occur act_score = results.get(translate_dict.get(row[1]), 0) prd_score = results.get(translate_dict.get(row[2]), 0) - row_text = str(row[1:]) - for query_word in query.split(" "): - if amt := query.count(query_word) > 0 and len(query_word) > 0: - best_row_scores[row[0]] = (act_score + prd_score) * amt - if query in row_text: - score = (best_row_scores.get(row[0], 0) + act_score + prd_score) * 2 - best_row_scores[row[0]] = score - else: - remain_row_scores[row[0]] = act_score + prd_score + row_scores[row[0]] = act_score + prd_score # finally only return the indices - best_sorted_indices = [identifier for identifier, _ in best_row_scores.most_common()] - remain_sorted_indices = [identifier for identifier, _ in remain_row_scores.most_common()] - sorted_indices = best_sorted_indices + remain_sorted_indices + sorted_indices = [identifier for identifier, _ in row_scores.most_common()] log.debug( f"ProductModel search in '{self.external_col_name}' ({len(self.dataframe)} items) " - f"found {len(sorted_indices)} ({len(best_sorted_indices)} literal) results " + f"found {len(sorted_indices)} results " f"for '{query}' in {time() - t:.2f} seconds ({t2 - t:.2f}s actual search, {time() - t2:.2f}s reorder for table)" ) return sorted_indices From 59e8e188066011894438dcf99e808cacd755af89 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Tue, 9 Sep 2025 12:23:40 +0200 Subject: [PATCH 038/267] marginal speed increases for initializing/updating for base class --- activity_browser/bwutils/searchengine/base.py | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index 3d3ffe18c..a6292c874 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -1,7 +1,7 @@ from itertools import permutations, chain import itertools import functools -from collections import Counter, OrderedDict +from collections import Counter, OrderedDict, defaultdict from logging import getLogger from time import time from typing import Iterable, Optional @@ -99,11 +99,17 @@ def update_index(self, update_df: pd.DataFrame) -> None: def update_dict(update_me: dict, new: dict) -> dict: """Update a dict of counters with new dict of counters.""" - for dict_key, _counter in new.items(): - if dict_key in update_me: - update_me[dict_key].update(_counter) - else: - update_me[dict_key] = _counter + # set to empty set if we know update_me is empty, otherwise, find set intersection + update_keys = set() if len(update_me) == 0 else new.keys() & update_me.keys() + if len(update_keys) == 0: + new_data = new + else: + for update_key in update_keys: + update_me[update_key].update(new[update_key]) + new_data = {key: value for key, value in new.items() if key not in update_keys} + # finally add any completely new data + # update_me.update(new_data) + update_me = update_me | new_data return update_me t = time() @@ -112,8 +118,10 @@ def update_dict(update_me: dict, new: dict) -> dict: # identifier to word and df i2w, update_df = self.words_in_df(update_df) self.identifier_to_word = update_dict(self.identifier_to_word, i2w) + for col in [col for col in update_df.columns if col not in self.df]: + col_data = [""] * len(self.df) + self.df[col] = col_data self.df = pd.concat([self.df, update_df]) - self.df = self.df.fillna("") # ensure we don't add unwanted NA through concatenations # word to identifier w2i = self.reverse_dict_many_to_one(i2w) @@ -126,7 +134,6 @@ def update_dict(update_me: dict, new: dict) -> dict: # q-gram to word q2w = self.reverse_dict_many_to_one(w2q) self.q_gram_to_word = update_dict(self.q_gram_to_word, q2w) - size_new = len(self.df) size_dif = size_new - size_old size_msg = (f"{size_dif} changed items at {int(round(size_dif/(time() - t), 0))} items/sec " @@ -153,13 +160,12 @@ def text_to_positional_q_gram(self, text: str) -> list: Note: these are technically _positional_ q-grams, but we don't use their positions currently. """ q = self.q - + n = len(text) # just return a single-item list if the text is equal or shorter than q # else, generate q-grams - if len(text) <= q: + if n <= q: return [text] - else: - return [text[i:i + q] for i in range(len(text) - q + 1)] + return list(text[i:i + q] for i in range(n - q + 1)) def words_in_df(self, df: pd.DataFrame = None) -> tuple[dict, pd.DataFrame]: """Return a dict of {identifier: word} for df.""" @@ -176,39 +182,37 @@ def words_in_df(self, df: pd.DataFrame = None) -> tuple[dict, pd.DataFrame]: col.append(line) identifier_word_dict[row[0]] = Counter(line.split(" ")) return_df["query_col"] = col + return_df = return_df.fillna("") # ensure we don't add unwanted NA in new data return identifier_word_dict, return_df def reverse_dict_many_to_one(self, dictionary: dict) -> dict: """Reverse a dictionary of Counter objects.""" - reverse = {} + reverse = defaultdict(Counter) for identifier, counter_object in dictionary.items(): for countable, count in counter_object.items(): - if countable not in reverse: - reverse[countable] = Counter() reverse[countable][identifier] += count - return reverse + return dict(reverse) def list_to_q_grams(self, word_list: Iterable) -> dict: """Convert a list of unique words to a dict with Counter objects. Number will be the occurrences of that q-gram in that word. - q_gram_dict = { + return = { "word": Counter( "wo": 1 "or": 1 "rd": 1 - ) + ), + ... } - """ - q_gram_dict = {} - - for word in word_list: - q_gram_dict[word] = Counter(self.text_to_positional_q_gram(word)) - - return q_gram_dict + text_to_q_gram = self.text_to_positional_q_gram + return { + word: Counter(text_to_q_gram(word)) + for word in word_list + } def word_in_index(self, word: str) -> bool: """Convenience function to check if a single word is in the search index.""" From e04c20e2dfc21699b2c959e15ee2226797b77831 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Tue, 9 Sep 2025 14:24:33 +0200 Subject: [PATCH 039/267] marginal speed increases for initializing/updating for base class --- activity_browser/bwutils/searchengine/base.py | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index a6292c874..91cc64e12 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -116,12 +116,15 @@ def update_dict(update_me: dict, new: dict) -> dict: size_old = len(self.df) # identifier to word and df + t2 = time() i2w, update_df = self.words_in_df(update_df) + log.debug(f">>> DF {time() - t2:.2f}.") self.identifier_to_word = update_dict(self.identifier_to_word, i2w) for col in [col for col in update_df.columns if col not in self.df]: col_data = [""] * len(self.df) self.df[col] = col_data self.df = pd.concat([self.df, update_df]) + log.debug(f">>> tot {time() - t2:.2f}.") # word to identifier w2i = self.reverse_dict_many_to_one(i2w) @@ -170,21 +173,15 @@ def text_to_positional_q_gram(self, text: str) -> list: def words_in_df(self, df: pd.DataFrame = None) -> tuple[dict, pd.DataFrame]: """Return a dict of {identifier: word} for df.""" - df = df if any(df) else self.df - return_df = df.copy() - - df = df.iloc[:, self.searchable_columns] - identifier_word_dict = {} - col = [] - - for row in df.itertuples(index=True): - line = self.clean_text(" | ".join(row[1:])) - col.append(line) - identifier_word_dict[row[0]] = Counter(line.split(" ")) - return_df["query_col"] = col - return_df = return_df.fillna("") # ensure we don't add unwanted NA in new data - - return identifier_word_dict, return_df + df = df if df is not None else self.df.copy() + df = df.fillna("") # avoid nan + # assemble query_col + df["query_col"] = df.iloc[:, self.searchable_columns].astype(str).agg(" | ".join, axis=1) + # clean all text at once using vectorized operations + df["query_col"] = df["query_col"].apply(self.clean_text) + # build the identifier_word_dict dictionary + identifier_word_dict = df["query_col"].apply(lambda text: Counter(text.split(" "))).to_dict() + return identifier_word_dict, df def reverse_dict_many_to_one(self, dictionary: dict) -> dict: """Reverse a dictionary of Counter objects.""" From 1bedc53ff507a8952702736d2688569ac87dc3e9 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Tue, 9 Sep 2025 17:12:09 +0200 Subject: [PATCH 040/267] Implement multiprocessing to increase speed for text cleaning during indexing. --- activity_browser/bwutils/searchengine/base.py | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index 91cc64e12..5a7752e3a 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -3,6 +3,8 @@ import functools from collections import Counter, OrderedDict, defaultdict from logging import getLogger +import math +import multiprocessing as mp from time import time from typing import Iterable, Optional import pandas as pd @@ -114,26 +116,16 @@ def update_dict(update_me: dict, new: dict) -> dict: t = time() size_old = len(self.df) - # identifier to word and df - t2 = time() i2w, update_df = self.words_in_df(update_df) - log.debug(f">>> DF {time() - t2:.2f}.") self.identifier_to_word = update_dict(self.identifier_to_word, i2w) - for col in [col for col in update_df.columns if col not in self.df]: - col_data = [""] * len(self.df) - self.df[col] = col_data self.df = pd.concat([self.df, update_df]) - log.debug(f">>> tot {time() - t2:.2f}.") - # word to identifier w2i = self.reverse_dict_many_to_one(i2w) self.word_to_identifier = update_dict(self.word_to_identifier, w2i) - # word to q-gram w2q = self.list_to_q_grams(w2i.keys()) self.word_to_q_grams = update_dict(self.word_to_q_grams, w2q) - # q-gram to word q2w = self.reverse_dict_many_to_one(w2q) self.q_gram_to_word = update_dict(self.q_gram_to_word, q2w) @@ -170,6 +162,38 @@ def text_to_positional_q_gram(self, text: str) -> list: return [text] return list(text[i:i + q] for i in range(n - q + 1)) + def df_clean_worker(self, df): + """Clean the text in query_col.""" + df["query_col"] = df["query_col"].apply(self.clean_text) + return df + + def df_clean(self, df): + """Clean the text in query_col. + + apply multi-processing when the computer is able and its relevant + """ + def chunk_dataframe(df: pd.DataFrame, chunk_size: int): + """Split DataFrame into chunks of specified size.""" + return [df.iloc[i:i + chunk_size] for i in range(0, len(df), chunk_size)] + + max_cores = max(1, mp.cpu_count() - 1) # leave at least 1 core for other processes + min_chunk_size = 2500 + if max_cores > 1 and len(df) > min_chunk_size * 2: + for i in range(max_cores, 0, -1): + chunk_size = int(math.ceil(len(df) / i)) + if chunk_size >= min_chunk_size: + break + use_cores = i + else: + use_cores = 1 + if use_cores == 1: + return self.df_clean_worker(df) + + chunks = chunk_dataframe(df, chunk_size) + with mp.Pool(processes=use_cores) as pool: + results = pool.starmap(self.df_clean_worker, [(chunk,) for chunk in chunks]) + return pd.concat(results) + def words_in_df(self, df: pd.DataFrame = None) -> tuple[dict, pd.DataFrame]: """Return a dict of {identifier: word} for df.""" @@ -178,7 +202,7 @@ def words_in_df(self, df: pd.DataFrame = None) -> tuple[dict, pd.DataFrame]: # assemble query_col df["query_col"] = df.iloc[:, self.searchable_columns].astype(str).agg(" | ".join, axis=1) # clean all text at once using vectorized operations - df["query_col"] = df["query_col"].apply(self.clean_text) + df["query_col"] = self.df_clean(df.loc[:, ["query_col"]]) # build the identifier_word_dict dictionary identifier_word_dict = df["query_col"].apply(lambda text: Counter(text.split(" "))).to_dict() return identifier_word_dict, df From 169a7cbe30690c004f179e76f50b9eb8dae5ea66 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Tue, 9 Sep 2025 17:12:39 +0200 Subject: [PATCH 041/267] Fix bug with incorrect text length settings --- activity_browser/ui/widgets/line_edit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/activity_browser/ui/widgets/line_edit.py b/activity_browser/ui/widgets/line_edit.py index 7095a5f88..9414fa878 100644 --- a/activity_browser/ui/widgets/line_edit.py +++ b/activity_browser/ui/widgets/line_edit.py @@ -264,7 +264,7 @@ def sanitize_input(self): self.clear() self.insertPlainText(clean_text) self.blockSignals(False) - cursor.setPosition(min(position, len(text))) + cursor.setPosition(min(position, len(clean_text))) self.setTextCursor(cursor) known_words = set() @@ -317,6 +317,7 @@ def _set_items(self): current_word = text[start:end] if not current_word: self.model.setStringList([]) + self.popup.close() return if self.auto_complete_word == current_word: # avoid unnecessary auto_complete calls if the current word didnt change From 7efab029f392ec3fa81f8207b03162ad27048080 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Tue, 9 Sep 2025 18:25:16 +0200 Subject: [PATCH 042/267] Fix to allow testing of metadatastore --- activity_browser/bwutils/metadata.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/activity_browser/bwutils/metadata.py b/activity_browser/bwutils/metadata.py index 32afb629b..6f96814fa 100644 --- a/activity_browser/bwutils/metadata.py +++ b/activity_browser/bwutils/metadata.py @@ -89,6 +89,8 @@ def on_node_deleted(self, ds): pass def remove_identifier_from_search_engine(self, ds): + if not hasattr(self, "search_engine"): + return data = model_to_dict(ds) identifier = data["id"] if identifier in self.search_engine.database_id_manager(data["database"]): @@ -96,6 +98,8 @@ def remove_identifier_from_search_engine(self, ds): self.search_engine.reset_database_id_manager() def remove_identifiers_from_search_engine(self, identifiers): + if not hasattr(self, "search_engine"): + return t = time() for identifier in identifiers: self.search_engine.remove_identifier(identifier, logging=False) @@ -132,12 +136,16 @@ def on_node_changed(self, new, old): self.thread().eventDispatcher().awake.connect(self._emitSyncLater, Qt.ConnectionType.UniqueConnection) def add_identifier_to_search_engine(self, data: pd.DataFrame): + if not hasattr(self, "search_engine"): + return search_engine_cols = list(set(data.columns) & set(self.search_engine_whitelist)) # intersection becomes columns data = data[search_engine_cols] self.search_engine.add_identifier(data.copy()) self.search_engine.reset_database_id_manager() def change_identifier_in_search_engine(self, identifier, data: pd.DataFrame): + if not hasattr(self, "search_engine"): + return search_engine_cols = list(set(data.columns) & set(self.search_engine_whitelist)) # intersection becomes columns data = data[search_engine_cols] self.search_engine.change_identifier(identifier=identifier, data=data.copy()) From 06747b839c94ac78a11c0d847a028c5c9562c92f Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Fri, 12 Sep 2025 17:02:08 +0200 Subject: [PATCH 043/267] Refactor textedit to proper location --- .../layouts/panes/database_products.py | 2 +- activity_browser/ui/widgets/line_edit.py | 260 +----------------- activity_browser/ui/widgets/text_edit.py | 251 +++++++++++++++++ 3 files changed, 254 insertions(+), 259 deletions(-) create mode 100644 activity_browser/ui/widgets/text_edit.py diff --git a/activity_browser/layouts/panes/database_products.py b/activity_browser/layouts/panes/database_products.py index 86228490a..475824266 100644 --- a/activity_browser/layouts/panes/database_products.py +++ b/activity_browser/layouts/panes/database_products.py @@ -60,7 +60,7 @@ def __init__(self, parent, db_name: str): self.model.has_external_search = True self.model.external_col_name = db_name - self.search = widgets.MetaDataAutoCompleteLineEdit(self) + self.search = widgets.MetaDataAutoCompleteTextEdit(self) self.search.database_name = db_name self.search.setMaximumHeight(30) self.search.setPlaceholderText("Quick Search") diff --git a/activity_browser/ui/widgets/line_edit.py b/activity_browser/ui/widgets/line_edit.py index 9414fa878..427663938 100644 --- a/activity_browser/ui/widgets/line_edit.py +++ b/activity_browser/ui/widgets/line_edit.py @@ -1,9 +1,6 @@ from qtpy import QtWidgets -from qtpy.QtCore import QTimer, Slot, Signal, SignalInstance, QStringListModel, Qt -from qtpy.QtGui import QTextFormat, QSyntaxHighlighter, QTextCharFormat, QTextDocument, QTextCursor -from qtpy.QtWidgets import QCompleter - -from activity_browser.bwutils import AB_metadata +from qtpy.QtCore import QTimer, Slot, Signal, SignalInstance +from qtpy.QtGui import QTextFormat class ABLineEdit(QtWidgets.QLineEdit): @@ -113,256 +110,3 @@ def focusOutEvent(self, event): self._before = after actions.ActivityModify.run(self._key, self._field, after) super(SignalledComboEdit, self).focusOutEvent(event) - - -class AutoCompleteLineEdit(QtWidgets.QLineEdit): - """Line Edit with a completer attached""" - - def __init__(self, items: list[str], parent=None): - super().__init__(parent=parent) - completer = QCompleter(items, self) - self.setCompleter(completer) - - -class MetaDataAutoCompleteLineEdit(ABLineEdit): - """Line Edit with MetaDataStore completer attached""" - - def __init__(self, parent=None): - super().__init__(parent=parent) - self.database_name = "" - - # autocompleter settings - self.model = QStringListModel() - self.completer = QCompleter(self.model) - self.popup = self.completer.popup() - self.popup.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.completer.setPopup(self.popup) - # allow all items in popup list - self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) - self.setCompleter(self.completer) - - # connect textEdited, this only triggers on user input, not Completer input - self.textEdited.connect(self._set_items) - - def _set_items(self, text=None): - if text is None: - text = self.text() - - # find the start and end of the word under the cursor - cursor_pos = self.cursorPosition() - start = cursor_pos - while start > 0 and text[start - 1] != " ": - start -= 1 - end = cursor_pos - while end < len(text) and text[end] != " ": - end += 1 - current_word = text[start:end] - if not current_word: - self.model.setStringList([]) - return - context = set((text[:start] + text[end:]).split(" ")) - - # get suggestions for the current word - alternatives = AB_metadata.auto_complete(current_word, context=context, database=self.database_name) - alternatives = alternatives[:6] # at most 6, though we should get ~3 usually - # replace the current word with each alternative - items = [] - for alt in alternatives: - new_text = text[:start] + alt + text[end:] - items.append(new_text) - print(text, items) - - self.model.setStringList(items) - # set correct height now that we have data - max_height = max( - 20, - self.popup.sizeHintForRow(0) * 3 + 2 * self.popup.frameWidth() - ) - self.popup.setMaximumHeight(max_height) - - -class UnknownWordHighlighter(QSyntaxHighlighter): - def __init__(self, parent: QTextDocument, known_words: set): - super().__init__(parent) - self.known_words = known_words - - # define the format for unknown words - self.unknown_format = QTextCharFormat() - self.unknown_format.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) - self.unknown_format.setUnderlineColor(Qt.red) - - def highlightBlock(self, text: str): - if text.startswith("="): - return - words = text.split() - index = 0 - for word in words: - word_len = len(word) - if word and word not in self.known_words: - self.setFormat(index, word_len, self.unknown_format) - index += word_len + 1 # +1 for the space - - -class ABTextEdit(QtWidgets.QTextEdit): - textChangedDebounce: SignalInstance = Signal(str) - _debounce_ms = 250 - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self._debounce_timer = QTimer(self, singleShot=True) - - self.textChanged.connect(self._set_debounce) - self._debounce_timer.timeout.connect(self._emit_debounce) - - def _set_debounce(self): - self._debounce_timer.setInterval(self._debounce_ms) - self._debounce_timer.start() - - def _emit_debounce(self): - self.textChangedDebounce.emit(self.toPlainText()) - - def debounce(self): - return self._debounce_ms - - def setDebounce(self, ms: int): - self._debounce_ms = ms - - -class MetaDataAutoCompleteLineEdit(ABTextEdit): - """Line Edit with MetaDataStore completer attached""" - - def __init__(self, parent=None): - super().__init__(parent=parent) - self.database_name = "" - self.auto_complete_word = "" - - # autocompleter settings - self.model = QStringListModel() - self.completer = QCompleter(self.model) - self.completer.setWidget(self) - self.popup = self.completer.popup() - self.popup.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.completer.setPopup(self.popup) - # allow all items in popup list - self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) - self.completer.activated.connect(self._insert_auto_complete) - - self.textChanged.connect(self.sanitize_input) - self.highlighter = UnknownWordHighlighter(self.document(), set()) - self.cursorPositionChanged.connect(self._set_items) - - def sanitize_input(self): - self._debounce_timer.stop() - text = self.toPlainText() - clean_text = AB_metadata.search_engine.ONE_SPACE_PATTERN.sub(" ", text) - - if clean_text != text: - cursor = self.textCursor() - position = cursor.position() - self.blockSignals(True) - self.clear() - self.insertPlainText(clean_text) - self.blockSignals(False) - cursor.setPosition(min(position, len(clean_text))) - self.setTextCursor(cursor) - - known_words = set() - for identifier in AB_metadata.search_engine.database_id_manager(self.database_name): - known_words.update(AB_metadata.search_engine.identifier_to_word[identifier].keys()) - self.highlighter.known_words = known_words - - if len(text) == 0: - self.popup.close() - self._set_debounce() - - def _insert_auto_complete(self, completion): - cursor = self.textCursor() - position = cursor.position() - text = self.toPlainText() - - start = position - while start > 0 and text[start - 1] != " ": - start -= 1 - new_position = start + len(completion) + 1 - - # select the word under the cursor - cursor.select(QTextCursor.WordUnderCursor) - # replace it with the completion - cursor.insertText(completion + " ") - # set the updated cursor to end of inserted word + space - cursor.setPosition(min(new_position, len(text[:start] + completion) + 1)) - self.setTextCursor(cursor) - - self.popup.close() - self.auto_complete_word = "" - self.model.setStringList([]) - - def _set_items(self): - text = self.toPlainText() - if text.startswith("="): - self.model.setStringList([]) - self.auto_complete_word = "" - self.popup.close() - return - - # find the start and end of the word under the cursor - cursor_pos = self.textCursor().position() - start = cursor_pos - while start > 0 and text[start - 1] != " ": - start -= 1 - end = cursor_pos - while end < len(text) and text[end] != " ": - end += 1 - current_word = text[start:end] - if not current_word: - self.model.setStringList([]) - self.popup.close() - return - if self.auto_complete_word == current_word: - # avoid unnecessary auto_complete calls if the current word didnt change - return - self.auto_complete_word = current_word - - context = set((text[:start] + text[end:]).split(" ")) - # get suggestions for the current word - alternatives = AB_metadata.auto_complete(current_word, context=context, database=self.database_name) - alternatives = alternatives[:6] # at most 6, though we should get ~3 usually - # replace the current word with each alternative - items = [] - for alt in alternatives: - new_text = text[:start] + alt + text[end:] - # items.append(new_text) - items.append(alt) - print(cursor_pos, text, items) - if len(items) == 0: - self.popup.close() - return - - self.model.setStringList(items) - # set correct height now that we have data - max_height = max( - 20, - self.popup.sizeHintForRow(0) * 3 + 2 * self.popup.frameWidth() - ) - self.popup.setMaximumHeight(max_height) - self.completer.complete() - - def keyPressEvent(self, event): - key = event.key() - - if key in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Tab): - # insert an autocomplete item - # capture enter/return/tab key - index = self.popup.currentIndex() - completion_text = index.data(Qt.DisplayRole) - self.completer.activated.emit(completion_text) - return - elif key in (Qt.Key_Space,): - self.popup.close() - - super().keyPressEvent(event) - - # trigger on text input keys - if event.text() or key in (Qt.LeftArrow, Qt.RightArrow): # filters out non-text keys like arrows, shift, etc. - self._set_items() diff --git a/activity_browser/ui/widgets/text_edit.py b/activity_browser/ui/widgets/text_edit.py new file mode 100644 index 000000000..aff4344ae --- /dev/null +++ b/activity_browser/ui/widgets/text_edit.py @@ -0,0 +1,251 @@ +from qtpy import QtWidgets +from qtpy.QtCore import QTimer, Signal, SignalInstance, QStringListModel, Qt +from qtpy.QtGui import QSyntaxHighlighter, QTextCharFormat, QTextDocument, QFont +from qtpy.QtWidgets import QCompleter, QStyledItemDelegate, QStyle + +from activity_browser.bwutils import AB_metadata + + +class UnknownWordHighlighter(QSyntaxHighlighter): + def __init__(self, parent: QTextDocument, known_words: set): + super().__init__(parent) + self.known_words = known_words + + # define the format for unknown words + self.unknown_format = QTextCharFormat() + self.unknown_format.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) + self.unknown_format.setUnderlineColor(Qt.red) + + def highlightBlock(self, text: str): + if text.startswith("="): + return + words = text.split() + index = 0 + for word in words: + word_len = len(word) + if word and word not in self.known_words: + self.setFormat(index, word_len, self.unknown_format) + index += word_len + 1 # +1 for the space + + +class AutoCompleteDelegate(QStyledItemDelegate): + def __init__(self, parent=None, get_bold_word_func=None): + super().__init__(parent) + self.get_bold_word_func = get_bold_word_func + + def paint(self, painter, option, index): + text = index.data(Qt.DisplayRole) + bold_words = self.get_bold_word_func() + bold_words = {word.lower() for word in bold_words} + + painter.save() + + # Draw selection background if selected + if option.state & QStyle.State_Selected: + painter.fillRect(option.rect, option.palette.highlight()) + painter.setPen(option.palette.highlightedText().color()) + else: + painter.setPen(option.palette.text().color()) + + # Split text into words and draw each with appropriate font + words = text.split(" ") + x = option.rect.x() + y = option.rect.y() + spacing = 4 # space between words + font = option.font + metrics = painter.fontMetrics() + + for word in words: + word_font = QFont(font) + if word.lower() in bold_words: + word_font.setBold(True) + painter.setFont(word_font) + + word_width = metrics.horizontalAdvance(word) + painter.drawText(x, y + metrics.ascent() + (option.rect.height() - metrics.height()) // 2, word) + x += word_width + spacing + painter.restore() + + +class ABTextEdit(QtWidgets.QTextEdit): + textChangedDebounce: SignalInstance = Signal(str) + _debounce_ms = 250 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._debounce_timer = QTimer(self, singleShot=True) + + self.textChanged.connect(self._set_debounce) + self._debounce_timer.timeout.connect(self._emit_debounce) + + def _set_debounce(self): + self._debounce_timer.setInterval(self._debounce_ms) + self._debounce_timer.start() + + def _emit_debounce(self): + self.textChangedDebounce.emit(self.toPlainText()) + + def debounce(self): + return self._debounce_ms + + def setDebounce(self, ms: int): + self._debounce_ms = ms + + +class ABAutoCompleTextEdit(ABTextEdit): + def __init__(self, parent=None, highlight_unknown=False): + super().__init__(parent=parent) + self.auto_complete_word = "" + self.auto_complete_suggestions = [] + + # autocompleter settings + self.model = QStringListModel() + self.completer = QCompleter(self.model) + self.completer.setWidget(self) + self.popup = self.completer.popup() + # set custom delegate to bold the current word + delegate = AutoCompleteDelegate(self.popup, get_bold_word_func=lambda: self.auto_complete_suggestions) + self.popup.setItemDelegate(delegate) + self.popup.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.completer.setPopup(self.popup) + self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) # allow all items in popup list + self.completer.activated.connect(self._insert_auto_complete) + + self.textChanged.connect(self._sanitize_input) + if highlight_unknown: + self.highlighter = UnknownWordHighlighter(self.document(), set()) + self.cursorPositionChanged.connect(self._set_autocomplete_items) + + def keyPressEvent(self, event): + key = event.key() + + if key in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Tab): + # insert an autocomplete item + # capture enter/return/tab key + index = self.popup.currentIndex() + completion_text = index.data(Qt.DisplayRole) + self.completer.activated.emit(completion_text) + return + elif key in (Qt.Key_Space,): + self.popup.close() + + super().keyPressEvent(event) + + # trigger on text input keys + if event.text() or key in (Qt.LeftArrow, Qt.RightArrow): # filters out non-text keys except l/r arrows + self._set_autocomplete_items() + + def _sanitize_input(self): + raise NotImplementedError + + def _set_autocomplete_items(self): + raise NotImplementedError + + def _insert_auto_complete(self, completion): + cursor = self.textCursor() + position = cursor.position() + completion = completion + " " # add space to end of new text + + # find where to put cursor back + new_position = position + while new_position < len(completion) and completion[new_position] != " ": + new_position += 1 + new_position += 1 # add one char for space + + # set new text from completion + self.blockSignals(True) + self.clear() + self.setText(completion) + # set the cursor location + cursor.setPosition(min(new_position, len(completion))) + self.setTextCursor(cursor) + self.blockSignals(False) + + # house keeping + self._emit_debounce() + self.popup.close() + self.auto_complete_word = "" + self.model.setStringList([]) + + +class MetaDataAutoCompleteTextEdit(ABAutoCompleTextEdit): + """TextEdit with MetaDataStore completer attached.""" + def __init__(self, parent=None): + super().__init__(parent=parent, highlight_unknown=True) + self.database_name = "" + + def _sanitize_input(self): + self._debounce_timer.stop() + text = self.toPlainText() + clean_text = AB_metadata.search_engine.ONE_SPACE_PATTERN.sub(" ", text) + + if clean_text != text: + cursor = self.textCursor() + position = cursor.position() + self.blockSignals(True) + self.clear() + self.insertPlainText(clean_text) + self.blockSignals(False) + cursor.setPosition(min(position, len(clean_text))) + self.setTextCursor(cursor) + + known_words = set() + for identifier in AB_metadata.search_engine.database_id_manager(self.database_name): + known_words.update(AB_metadata.search_engine.identifier_to_word[identifier].keys()) + self.highlighter.known_words = known_words + + if len(text) == 0: + self.popup.close() + self._set_debounce() + + def _set_autocomplete_items(self): + text = self.toPlainText() + if text.startswith("="): + self.model.setStringList([]) + self.auto_complete_word = "" + self.popup.close() + return + + # find the start and end of the word under the cursor + cursor = self.textCursor() + position = cursor.position() + start = position + while start > 0 and text[start - 1] != " ": + start -= 1 + end = position + while end < len(text) and text[end] != " ": + end += 1 + current_word = text[start:end] + if not current_word: + self.model.setStringList([]) + self.popup.close() + self.auto_complete_word = "" + return + if self.auto_complete_word == current_word: + # avoid unnecessary auto_complete calls if the current word didnt change + return + self.auto_complete_word = current_word + + context = set((text[:start] + text[end:]).split(" ")) + # get suggestions for the current word + suggestions = AB_metadata.auto_complete(current_word, context=context, database=self.database_name) + suggestions = suggestions[:6] # at most 6, though we should get ~3 usually + self.auto_complete_suggestions = suggestions # set for bolding of autocomplete suggestions + # replace the current word with each alternative + items = [] + for alt in suggestions: + new_text = text[:start] + alt + text[end:] + items.append(new_text) + if len(items) == 0: + self.popup.close() + return + + self.model.setStringList(items) + # set correct height now that we have data + max_height = max( + 20, + self.popup.sizeHintForRow(0) * 3 + 2 * self.popup.frameWidth() + ) + self.popup.setMaximumHeight(max_height) + self.completer.complete() From 90583c668e5fcbdcfb1e357cf6ae13a5673352ac Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Fri, 12 Sep 2025 17:02:11 +0200 Subject: [PATCH 044/267] Refactor textedit to proper location --- activity_browser/ui/widgets/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index 333811439..89d2c30ca 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -1,8 +1,8 @@ from .abstract_pane import ABAbstractPane from .comparison_switch import SwitchComboBox from .cutoff_menu import CutoffMenu -from .line_edit import (ABLineEdit, SignalledComboEdit, SignalledLineEdit, - SignalledPlainTextEdit, MetaDataAutoCompleteLineEdit) +from .line_edit import ABLineEdit, SignalledComboEdit, SignalledLineEdit, SignalledPlainTextEdit +from .text_edit import MetaDataAutoCompleteTextEdit from .treeview import ABTreeView from .item_model import ABItemModel from .item import ABAbstractItem, ABBranchItem, ABDataItem From fecbcf20cb8f4c6bf5f2c267b9fdb20b2ef68ab6 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Tue, 16 Sep 2025 11:34:33 +0200 Subject: [PATCH 045/267] Implement search caching for faster results --- .../bwutils/searchengine/metadata_search.py | 143 +++++++++++++++--- 1 file changed, 123 insertions(+), 20 deletions(-) diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py index 374ca56e0..1814a3e8a 100644 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -12,17 +12,22 @@ class MetaDataSearchEngine(SearchEngine): + + # caching for faster operation def database_id_manager(self, database): if not hasattr(self, "all_database_ids"): self.all_database_ids = {} if database_ids := self.all_database_ids.get(database): self.database_ids = database_ids + self.current_database = database elif database is not None: self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) self.all_database_ids[database] = self.database_ids + self.current_database = database else: self.database_ids = None + self.current_database = "_@@NO_DB_" return self.database_ids def reset_database_id_manager(self): @@ -31,10 +36,54 @@ def reset_database_id_manager(self): if hasattr(self, "database_ids"): del self.database_ids - def add_identifier(self, data: pd.DataFrame) -> None: - super().add_identifier(data) + def database_word_manager(self, database): + if not hasattr(self, "all_database_words"): + self.all_database_words = {} + + if database_words := self.all_database_words.get(database): + self.database_words = database_words + elif database is not None: + ids = self.database_id_manager(database) + self.database_words = self.reverse_dict_many_to_one({_id: self.identifier_to_word[_id] for _id in ids}) + self.all_database_words[database] = self.database_words + else: + self.database_words = None + return self.database_words + + def reset_database_word_manager(self, database): + if hasattr(self, "all_database_words") and self.all_database_words.get(database): + del self.all_database_words[database] + if hasattr(self, "database_words"): + del self.database_words + + def database_search_cache(self, database, query, result = None): + if not hasattr(self, "search_cache"): + self.search_cache = {} + + if result: + if self.search_cache.get(database): + self.search_cache[database][query] = result + else: + self.search_cache[database] = {query: result} + return + if db_cache := self.search_cache.get(database): + if cached_result := db_cache.get(query): + return cached_result + return + + def reset_search_cache(self, database): + if hasattr(self, "search_cache") and self.search_cache.get(database): + del self.search_cache[database] + + def reset_all_caches(self, databases): self.reset_database_id_manager() + for database in databases: + self.reset_database_word_manager(database) + self.reset_search_cache(database) + def add_identifier(self, data: pd.DataFrame) -> None: + super().add_identifier(data) + self.reset_all_caches(data["database"].unique()) def remove_identifiers(self, identifiers, logging=True) -> None: t = time() @@ -42,6 +91,7 @@ def remove_identifiers(self, identifiers, logging=True) -> None: identifiers = set(identifiers) current_identifiers = set(self.df.index.to_list()) identifiers = identifiers | current_identifiers # only remove identifiers currently in the data + databases = self.df.loc[identifiers, ["databases"]].unique() # extract databases for cache cleaning if len(identifiers) == 0: return @@ -51,11 +101,11 @@ def remove_identifiers(self, identifiers, logging=True) -> None: if logging: log.debug(f"Search index updated in {time() - t:.2f} seconds " f"for {len(identifiers)} removed items ({len(self.df)} items ({self.size_of_index()}) currently).") - self.reset_database_id_manager() + self.reset_all_caches(databases) def change_identifier(self, identifier, data: pd.DataFrame) -> None: super().change_identifier(identifier, data) - self.reset_database_id_manager() + self.reset_all_caches(data["database"].unique()) def auto_complete(self, word: str, context: Optional[set] = set(), database: Optional[str] = None) -> list: """Based on spellchecker, make more useful for autocompletions @@ -188,6 +238,53 @@ def find_q_gram_matches(self, q_grams: set, return_all: bool = False) -> pd.Data return matches.iloc[:min(len(matches), 2500), :] # return at most this many results + def search_size_1(self, queries: list, original_words: set, orig_word_weight=5, exact_word_weight=1) -> dict: + """Return a dict of {query_word: Counter(identifier)}. + + queries: is a list of len 1 tuple/lists of words that are a searched word or a 'spell checked' similar word + original words: a list of words actually searched for (not including spellchecked) + + orig_word_weight: additional weight to add to original words + exact_word_weight: additional weight to add to exact word matches (as opposed to be 'in' str) + + First, we find all matching words, creating a dict of words in 'queries' as keys and words matching that query word as list of values + Next, we convert this to identifiers and add weights: + Weight will be increased if matching 'orig_word_weight' or 'exact_word_weight' + """ + matches = {} + t2 = time() + # add each word in search index if query_word in word + for word in self.database_words.keys(): + for query in queries: + # query is list/tuple of len 1 + query_word = query[0] # only use the word + if query_word in word: + words = matches.get(query_word, []) + words.extend([word]) + matches[query_word] = words + + # now convert matched words to matched identifiers + matched_identifiers = {} + for word, matching_words in matches.items(): + if result := self.database_search_cache(self.current_database, word): + matched_identifiers[word] = result + continue + id_counter = matched_identifiers.get(word, Counter()) + for matched_word in matching_words: + weight = self.base_weight + + # add the word n times, where n is the weight, original search word is weighted higher than alternatives + if matched_word in original_words: + weight += orig_word_weight # increase weight for original word + if matched_word == word: + weight += exact_word_weight # increase weight for exact matching word + + id_counter = self.weigh_identifiers(self.database_words[matched_word], weight, id_counter) + matched_identifiers[word] = id_counter + self.database_search_cache(self.current_database, word, matched_identifiers[word]) + + return matched_identifiers + def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter: bool = False, logging: bool = True) -> list: """Overwritten for extra database specific reduction of results. """ @@ -200,6 +297,7 @@ def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter # DATABASE SPECIFIC get the set of ids that is in this database self.database_id_manager(database) + self.database_word_manager(database) queries = self.build_queries(text) @@ -279,17 +377,21 @@ def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter # now search for all permutations of this query combined with a space query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] for query_perm in permutations(query): - mask = self.filter_dataframe(query_df, " ".join(query_perm), search_columns=["query_col"]) - new_df = query_df.loc[mask].reset_index(drop=True) - if len(new_df) == 0: - # there is no match for this permutation of words, skip - continue - new_id_list = new_df[self.identifier_name] - - new_ids = Counter() - for new_id in new_id_list: - new_ids[new_id] = query_identifiers[new_id] - + query_perm_str = " ".join(query_perm) + if result := self.database_search_cache(self.current_database, query_perm_str): + new_ids = result + else: + mask = self.filter_dataframe(query_df, query_perm_str, search_columns=["query_col"]) + new_df = query_df.loc[mask].reset_index(drop=True) + if len(new_df) == 0: + # there is no match for this permutation of words, skip + continue + new_id_list = new_df[self.identifier_name] + + new_ids = Counter() + for new_id in new_id_list: + new_ids[new_id] = query_identifiers[new_id] + self.database_search_cache(self.current_database, query_perm_str, new_ids) # we weigh a combination of words that is next also to each other even higher than just the words separately query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, query_to_identifier[query_name]) @@ -298,14 +400,15 @@ def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter for identifiers in query_to_identifier.values(): all_identifiers += identifiers + if return_counter: + return_this = all_identifiers + else: + # now sort on highest weights and make list type + return_this = [identifier[0] for identifier in all_identifiers.most_common()] if logging: log.debug( f"Found {len(all_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - if return_counter: - return all_identifiers - # now sort on highest weights and make list type - sorted_identifiers = [identifier[0] for identifier in all_identifiers.most_common()] - return sorted_identifiers + return return_this def search(self, text, database: Optional[str] = None) -> list: """Search the dataframe on this text, return a sorted list of identifiers.""" From 9734ad2c467b549b87caeb1e23b4e768c8750e66 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Tue, 16 Sep 2025 11:55:52 +0200 Subject: [PATCH 046/267] bold only current word, not all search suggested words --- activity_browser/ui/widgets/text_edit.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/activity_browser/ui/widgets/text_edit.py b/activity_browser/ui/widgets/text_edit.py index aff4344ae..9daf4fabe 100644 --- a/activity_browser/ui/widgets/text_edit.py +++ b/activity_browser/ui/widgets/text_edit.py @@ -29,14 +29,12 @@ def highlightBlock(self, text: str): class AutoCompleteDelegate(QStyledItemDelegate): - def __init__(self, parent=None, get_bold_word_func=None): + def __init__(self, parent=None): super().__init__(parent) - self.get_bold_word_func = get_bold_word_func + self.current_word_index = -1 def paint(self, painter, option, index): text = index.data(Qt.DisplayRole) - bold_words = self.get_bold_word_func() - bold_words = {word.lower() for word in bold_words} painter.save() @@ -55,9 +53,9 @@ def paint(self, painter, option, index): font = option.font metrics = painter.fontMetrics() - for word in words: + for i, word in enumerate(words): word_font = QFont(font) - if word.lower() in bold_words: + if i+1 == self.current_word_index: word_font.setBold(True) painter.setFont(word_font) @@ -97,16 +95,14 @@ class ABAutoCompleTextEdit(ABTextEdit): def __init__(self, parent=None, highlight_unknown=False): super().__init__(parent=parent) self.auto_complete_word = "" - self.auto_complete_suggestions = [] # autocompleter settings self.model = QStringListModel() self.completer = QCompleter(self.model) self.completer.setWidget(self) self.popup = self.completer.popup() - # set custom delegate to bold the current word - delegate = AutoCompleteDelegate(self.popup, get_bold_word_func=lambda: self.auto_complete_suggestions) - self.popup.setItemDelegate(delegate) + self.delegate = AutoCompleteDelegate(self.popup) # set custom delegate to bold the current word + self.popup.setItemDelegate(self.delegate) self.popup.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.completer.setPopup(self.popup) self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) # allow all items in popup list @@ -228,10 +224,10 @@ def _set_autocomplete_items(self): self.auto_complete_word = current_word context = set((text[:start] + text[end:]).split(" ")) + self.delegate.current_word_index = len(text[:start].split(" ")) # current word index for bolding # get suggestions for the current word suggestions = AB_metadata.auto_complete(current_word, context=context, database=self.database_name) suggestions = suggestions[:6] # at most 6, though we should get ~3 usually - self.auto_complete_suggestions = suggestions # set for bolding of autocomplete suggestions # replace the current word with each alternative items = [] for alt in suggestions: From e342f2247f82f0980ea912b5ae604f9ac59ea29b Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Tue, 16 Sep 2025 12:51:42 +0200 Subject: [PATCH 047/267] enable dealing with empty metadata in tests --- activity_browser/bwutils/searchengine/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index 5a7752e3a..5b9127985 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -114,6 +114,9 @@ def update_dict(update_me: dict, new: dict) -> dict: update_me = update_me | new_data return update_me + if len(update_df) == 0: + return + t = time() size_old = len(self.df) # identifier to word and df From 8d3c05c88178b086447bc39b3a75c34a05dc9ca2 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sun, 26 Oct 2025 13:24:18 +0100 Subject: [PATCH 048/267] Moving stuff around --- activity_browser/__init__.py | 2 +- .../actions/activity/activity_new_process.py | 2 +- .../actions/metadatastore_open.py | 2 +- .../layouts/panes/database_explorer.py | 3 +- activity_browser/ui/{ => core}/application.py | 24 ---- .../database_selection_dialog.py | 0 .../ui/{widgets => dialogs}/dialog.py | 0 .../{widgets => dialogs}/list_edit_dialog.py | 0 .../{widgets => dialogs}/new_node_dialog.py | 122 +++++++++--------- .../{widgets => dialogs}/progress_dialog.py | 0 .../ui/{wizards => dialogs}/uncertainty.py | 4 +- activity_browser/ui/menu_bar.py | 14 -- activity_browser/ui/widgets/__init__.py | 6 +- activity_browser/ui/wizards/__init__.py | 2 +- tests/actions/test_activity_actions.py | 2 +- 15 files changed, 73 insertions(+), 110 deletions(-) rename activity_browser/ui/{ => core}/application.py (78%) rename activity_browser/ui/{widgets => dialogs}/database_selection_dialog.py (100%) rename activity_browser/ui/{widgets => dialogs}/dialog.py (100%) rename activity_browser/ui/{widgets => dialogs}/list_edit_dialog.py (100%) rename activity_browser/ui/{widgets => dialogs}/new_node_dialog.py (97%) rename activity_browser/ui/{widgets => dialogs}/progress_dialog.py (100%) rename activity_browser/ui/{wizards => dialogs}/uncertainty.py (99%) diff --git a/activity_browser/__init__.py b/activity_browser/__init__.py index 1e67a74f5..37e699f04 100644 --- a/activity_browser/__init__.py +++ b/activity_browser/__init__.py @@ -14,7 +14,7 @@ except ImportError: import qtpy -from .ui.application import application +from .ui.core.application import application from .signals import signals def run_activity_browser(): diff --git a/activity_browser/actions/activity/activity_new_process.py b/activity_browser/actions/activity/activity_new_process.py index 425ccbb56..f2b32d5b5 100644 --- a/activity_browser/actions/activity/activity_new_process.py +++ b/activity_browser/actions/activity/activity_new_process.py @@ -6,7 +6,7 @@ from activity_browser import application, bwutils from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -from activity_browser.ui.widgets.new_node_dialog import NewNodeDialog +from activity_browser.ui.dialogs.new_node_dialog import NewNodeDialog from .activity_open import ActivityOpen diff --git a/activity_browser/actions/metadatastore_open.py b/activity_browser/actions/metadatastore_open.py index ad59cc35a..474ea0988 100644 --- a/activity_browser/actions/metadatastore_open.py +++ b/activity_browser/actions/metadatastore_open.py @@ -3,7 +3,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -from activity_browser.ui.application import global_shortcut +from activity_browser.ui.core.application import global_shortcut log = getLogger(__name__) diff --git a/activity_browser/layouts/panes/database_explorer.py b/activity_browser/layouts/panes/database_explorer.py index 909512b98..17159f633 100644 --- a/activity_browser/layouts/panes/database_explorer.py +++ b/activity_browser/layouts/panes/database_explorer.py @@ -7,7 +7,8 @@ from activity_browser import signals from activity_browser.bwutils import AB_metadata -from activity_browser.ui import widgets, application +from activity_browser.ui import widgets +from activity_browser.ui.core import application log = getLogger(__name__) diff --git a/activity_browser/ui/application.py b/activity_browser/ui/core/application.py similarity index 78% rename from activity_browser/ui/application.py rename to activity_browser/ui/core/application.py index f75a365ce..8a262a6f9 100644 --- a/activity_browser/ui/application.py +++ b/activity_browser/ui/core/application.py @@ -1,5 +1,3 @@ -import sys - from pathlib import Path from logging import getLogger @@ -114,25 +112,3 @@ def decorator(func): application = ABApplication() - - - -# -# if QSysInfo.productType() == "osx": -# # https://bugreports.qt.io/browse/QTBUG-87014 -# # https://bugreports.qt.io/browse/QTBUG-85546 -# # https://github.com/mapeditor/tiled/issues/2845 -# # https://doc.qt.io/qt-5/qoperatingsystemversion.html#MacOSBigSur-var -# supported = {"10.10", "10.11", "10.12", "10.13", "10.14", "10.15", "13.6"} -# if QSysInfo.productVersion() not in supported: -# os.environ["QT_MAC_WANTS_LAYER"] = "1" -# os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" -# log.info("Info: GPU hardware acceleration disabled") -# -# # on macos buttons silently crashes the renderer without any logs -# # confirmed that buttons works on the latest version of qt using pyside6 -# if QSysInfo.productType() in ["arch", "nixos", "osx"]: -# os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "{} --no-sandbox".format( -# os.getenv("QTWEBENGINE_CHROMIUM_FLAGS") -# ) -# log.info("Info: QtWebEngine sandbox disabled") diff --git a/activity_browser/ui/widgets/database_selection_dialog.py b/activity_browser/ui/dialogs/database_selection_dialog.py similarity index 100% rename from activity_browser/ui/widgets/database_selection_dialog.py rename to activity_browser/ui/dialogs/database_selection_dialog.py diff --git a/activity_browser/ui/widgets/dialog.py b/activity_browser/ui/dialogs/dialog.py similarity index 100% rename from activity_browser/ui/widgets/dialog.py rename to activity_browser/ui/dialogs/dialog.py diff --git a/activity_browser/ui/widgets/list_edit_dialog.py b/activity_browser/ui/dialogs/list_edit_dialog.py similarity index 100% rename from activity_browser/ui/widgets/list_edit_dialog.py rename to activity_browser/ui/dialogs/list_edit_dialog.py diff --git a/activity_browser/ui/widgets/new_node_dialog.py b/activity_browser/ui/dialogs/new_node_dialog.py similarity index 97% rename from activity_browser/ui/widgets/new_node_dialog.py rename to activity_browser/ui/dialogs/new_node_dialog.py index 8e72ad144..c723d60cb 100644 --- a/activity_browser/ui/widgets/new_node_dialog.py +++ b/activity_browser/ui/dialogs/new_node_dialog.py @@ -1,61 +1,61 @@ - -from typing import Optional, Tuple -from qtpy.QtWidgets import QDialog, QGridLayout, QLabel, QLineEdit, QPushButton, QWidget - - -class NewNodeDialog(QDialog): - """ - Gathers the paremeters for creating a new process. - """ - - def __init__(self, process: bool = True, parent: Optional[QWidget] = None): - super().__init__(parent) - layout = QGridLayout() - row = 0 - if process: - self.setWindowTitle("New process") - layout.addWidget(QLabel("Process name"), row, 0) - else: - self.setWindowTitle("New product") - layout.addWidget(QLabel("Product name"), row, 0) - self._process_name_edit = QLineEdit() - self._process_name_edit.textChanged.connect(self._handle_text_changed) - layout.addWidget(self._process_name_edit, row, 1) - row += 1 - self._ref_product_name_edit = QLineEdit() - if process: - layout.addWidget(QLabel("Product name"), row, 0) - layout.addWidget(self._ref_product_name_edit, row, 1) - row += 1 - layout.addWidget(QLabel("Unit"), row, 0) - self._unit_edit = QLineEdit("kilogram") - layout.addWidget(self._unit_edit, row, 1) - row += 1 - layout.addWidget(QLabel("Location"), row, 0) - default_loc = "GLO" if process else "" - self._location_edit = QLineEdit(default_loc) - layout.addWidget(self._location_edit, row, 1) - row += 1 - self._ok_button = QPushButton("OK") - self._ok_button.clicked.connect(self.accept) - self._ok_button.setEnabled(False) - layout.addWidget(self._ok_button, row, 0) - cancel_button = QPushButton("Cancel") - cancel_button.clicked.connect(self.reject) - layout.addWidget(cancel_button, row, 1) - self.setLayout(layout) - - def _handle_text_changed(self, text: str): - self._ok_button.setEnabled(text != "") - self._ref_product_name_edit.setPlaceholderText(text) - - def get_new_process_data(self) -> Tuple[str, str, str, str]: - """Return the parameters the user entered.""" - return ( - self._process_name_edit.text(), - self._ref_product_name_edit.text(), - self._unit_edit.text(), - self._location_edit.text() - ) - - + +from typing import Optional, Tuple +from qtpy.QtWidgets import QDialog, QGridLayout, QLabel, QLineEdit, QPushButton, QWidget + + +class NewNodeDialog(QDialog): + """ + Gathers the paremeters for creating a new process. + """ + + def __init__(self, process: bool = True, parent: Optional[QWidget] = None): + super().__init__(parent) + layout = QGridLayout() + row = 0 + if process: + self.setWindowTitle("New process") + layout.addWidget(QLabel("Process name"), row, 0) + else: + self.setWindowTitle("New product") + layout.addWidget(QLabel("Product name"), row, 0) + self._process_name_edit = QLineEdit() + self._process_name_edit.textChanged.connect(self._handle_text_changed) + layout.addWidget(self._process_name_edit, row, 1) + row += 1 + self._ref_product_name_edit = QLineEdit() + if process: + layout.addWidget(QLabel("Product name"), row, 0) + layout.addWidget(self._ref_product_name_edit, row, 1) + row += 1 + layout.addWidget(QLabel("Unit"), row, 0) + self._unit_edit = QLineEdit("kilogram") + layout.addWidget(self._unit_edit, row, 1) + row += 1 + layout.addWidget(QLabel("Location"), row, 0) + default_loc = "GLO" if process else "" + self._location_edit = QLineEdit(default_loc) + layout.addWidget(self._location_edit, row, 1) + row += 1 + self._ok_button = QPushButton("OK") + self._ok_button.clicked.connect(self.accept) + self._ok_button.setEnabled(False) + layout.addWidget(self._ok_button, row, 0) + cancel_button = QPushButton("Cancel") + cancel_button.clicked.connect(self.reject) + layout.addWidget(cancel_button, row, 1) + self.setLayout(layout) + + def _handle_text_changed(self, text: str): + self._ok_button.setEnabled(text != "") + self._ref_product_name_edit.setPlaceholderText(text) + + def get_new_process_data(self) -> Tuple[str, str, str, str]: + """Return the parameters the user entered.""" + return ( + self._process_name_edit.text(), + self._ref_product_name_edit.text(), + self._unit_edit.text(), + self._location_edit.text() + ) + + diff --git a/activity_browser/ui/widgets/progress_dialog.py b/activity_browser/ui/dialogs/progress_dialog.py similarity index 100% rename from activity_browser/ui/widgets/progress_dialog.py rename to activity_browser/ui/dialogs/progress_dialog.py diff --git a/activity_browser/ui/wizards/uncertainty.py b/activity_browser/ui/dialogs/uncertainty.py similarity index 99% rename from activity_browser/ui/wizards/uncertainty.py rename to activity_browser/ui/dialogs/uncertainty.py index e78b184c5..7a680e9d0 100644 --- a/activity_browser/ui/wizards/uncertainty.py +++ b/activity_browser/ui/dialogs/uncertainty.py @@ -7,11 +7,11 @@ from stats_arrays.distributions import * from activity_browser import actions -from .. import application +from ..core import application from ...bwutils import PedigreeMatrix, get_uncertainty_interface from ...bwutils.uncertainty import EMPTY_UNCERTAINTY -from ..figures import SimpleDistributionPlot +from ..figures.figures import SimpleDistributionPlot log = getLogger(__name__) diff --git a/activity_browser/ui/menu_bar.py b/activity_browser/ui/menu_bar.py index a70eafb3d..4406f0995 100644 --- a/activity_browser/ui/menu_bar.py +++ b/activity_browser/ui/menu_bar.py @@ -150,20 +150,6 @@ def sync(self): self.addAction(action) -# class ToolsMenu(QtWidgets.QMenu): -# """ -# Tools Menu: contains actions in regard to special tooling aspects of the AB -# """ -# -# def __init__(self, parent=None) -> None: -# super().__init__(parent) -# self.setTitle("&Tools") -# -# self.manage_plugins_action = actions.PluginWizardOpen.get_QAction() -# -# self.addAction(self.manage_plugins_action) - - class HelpMenu(QtWidgets.QMenu): """ Help Menu: contains actions that show info to the user or redirect them to online resources diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index d92fe1773..8b8afef15 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -8,7 +8,7 @@ from .item import ABAbstractItem, ABBranchItem, ABDataItem from .line import ABHLine, ABVLine from .formula_edit import ABFormulaEdit -from .progress_dialog import ABProgressDialog +from ..dialogs.progress_dialog import ABProgressDialog from .combobox import ABComboBox from .button_collapser import ABRadioButtonCollapser @@ -21,6 +21,6 @@ from .main_window import MainWindow from .central import CentralTabWidget from .menu import ABMenu -from .list_edit_dialog import ABListEditDialog +from ..dialogs.list_edit_dialog import ABListEditDialog from .drop_overlay import ABDropOverlay -from .database_selection_dialog import ABDatabaseSelectionDialog +from ..dialogs.database_selection_dialog import ABDatabaseSelectionDialog diff --git a/activity_browser/ui/wizards/__init__.py b/activity_browser/ui/wizards/__init__.py index c23411065..0eb2c33ff 100644 --- a/activity_browser/ui/wizards/__init__.py +++ b/activity_browser/ui/wizards/__init__.py @@ -1 +1 @@ -from .uncertainty import UncertaintyWizard +from ..dialogs.uncertainty import UncertaintyWizard diff --git a/tests/actions/test_activity_actions.py b/tests/actions/test_activity_actions.py index ffba13834..b68dafde2 100644 --- a/tests/actions/test_activity_actions.py +++ b/tests/actions/test_activity_actions.py @@ -48,7 +48,7 @@ def test_activity_duplicate(basic_database): # # def test_activity_new(monkeypatch, basic_database): - from activity_browser.ui.widgets.new_node_dialog import NewNodeDialog + from activity_browser.ui.dialogs.new_node_dialog import NewNodeDialog monkeypatch.setattr( NewNodeDialog, "exec_", staticmethod(lambda *args, **kwargs: True) From e46fe3e1e431cc8497c0690ce04ca963570f524c Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sun, 26 Oct 2025 13:29:52 +0100 Subject: [PATCH 049/267] Refactor import statement for SimpleDistributionPlot to improve module organization --- activity_browser/ui/dialogs/uncertainty.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activity_browser/ui/dialogs/uncertainty.py b/activity_browser/ui/dialogs/uncertainty.py index 7a680e9d0..699f32453 100644 --- a/activity_browser/ui/dialogs/uncertainty.py +++ b/activity_browser/ui/dialogs/uncertainty.py @@ -11,7 +11,7 @@ from ...bwutils import PedigreeMatrix, get_uncertainty_interface from ...bwutils.uncertainty import EMPTY_UNCERTAINTY -from ..figures.figures import SimpleDistributionPlot +from ..figures import SimpleDistributionPlot log = getLogger(__name__) From 6a78feac51602388706a7ad1aaa433aeb91ab96e Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sun, 26 Oct 2025 13:43:02 +0100 Subject: [PATCH 050/267] Refactor plotting classes to inherit from ABPlot and remove deprecated Plot class --- .../layouts/pages/lca_results/plots.py | 82 +--- activity_browser/ui/dialogs/uncertainty.py | 27 +- activity_browser/ui/figures.py | 390 ------------------ activity_browser/ui/widgets/__init__.py | 1 + activity_browser/ui/widgets/plot.py | 65 +++ 5 files changed, 96 insertions(+), 469 deletions(-) delete mode 100644 activity_browser/ui/figures.py create mode 100644 activity_browser/ui/widgets/plot.py diff --git a/activity_browser/layouts/pages/lca_results/plots.py b/activity_browser/layouts/pages/lca_results/plots.py index 13a367683..4ee5c695e 100644 --- a/activity_browser/layouts/pages/lca_results/plots.py +++ b/activity_browser/layouts/pages/lca_results/plots.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import math from logging import getLogger @@ -6,85 +5,16 @@ import numpy as np import pandas as pd import seaborn as sns -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg -from matplotlib.figure import Figure -from qtpy import QtWidgets from bw2data import methods -from activity_browser.utils import savefilepath +from activity_browser.ui.widgets import ABPlot from activity_browser.bwutils.commontasks import wrap_text log = getLogger(__name__) -# todo: sizing of the figures needs to be improved and systematized... -# todo: Bokeh is a potential alternative as it allows interactive visualizations, -# but this issue needs to be resolved first: https://github.com/bokeh/bokeh/issues/8169 - -class Plot(QtWidgets.QWidget): - ALL_FILTER = "All Files (*.*)" - PNG_FILTER = "PNG (*.png)" - SVG_FILTER = "SVG (*.svg)" - - def __init__(self, parent=None): - super().__init__(parent) - # create figure, canvas, and axis - # self.figure = Figure(tight_layout=True) - self.figure = Figure(constrained_layout=True) - self.canvas = FigureCanvasQTAgg(self.figure) - self.canvas.setMinimumHeight(0) - - self.canvas.destroyed.connect(self.check) - - self.ax = self.figure.add_subplot(111) # create an axis - self.plot_name = "Figure" - - # set the layout - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.canvas) - self.setLayout(layout) - self.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding - ) - self.updateGeometry() - - def check(self): - print("WHY DELETE") - - def plot(self, *args, **kwargs): - raise NotImplementedError - - def reset_plot(self) -> None: - self.figure.clf() - self.ax = self.figure.add_subplot(111) - - def get_canvas_size_in_inches(self): - # print("Canvas size:", self.canvas.get_width_height()) - return tuple(x / self.figure.dpi for x in self.canvas.get_width_height()) - - def to_png(self): - """Export to .png format.""" - filepath = savefilepath( - default_file_name=self.plot_name, file_filter=self.PNG_FILTER - ) - if filepath: - if not filepath.endswith(".png"): - filepath += ".png" - self.figure.savefig(filepath) - - def to_svg(self): - """Export to .svg format.""" - filepath = savefilepath( - default_file_name=self.plot_name, file_filter=self.SVG_FILTER - ) - if filepath: - if not filepath.endswith(".svg"): - filepath += ".svg" - self.figure.savefig(filepath) - - -class LCAResultsBarChart(Plot): +class LCAResultsBarChart(ABPlot): """ " Generate a bar chart comparing the absolute LCA scores of the products""" def __init__(self, parent=None): @@ -116,7 +46,7 @@ def plot(self, df: pd.DataFrame, method: tuple, labels: list): self.canvas.draw() -class LCAResultsPlot(Plot): +class LCAResultsPlot(ABPlot): def __init__(self, parent=None): super().__init__(parent) self.plot_name = "LCA heatmap" @@ -180,7 +110,7 @@ def plot(self, df: pd.DataFrame, invert_plot: bool = False): self.canvas.draw() -class ContributionPlot(Plot): +class ContributionPlot(ABPlot): MAX_LEGEND = 30 def __init__(self, parent=None): @@ -280,7 +210,7 @@ def plot(self, df: pd.DataFrame, unit: str = None): self.canvas.draw() -class CorrelationPlot(Plot): +class CorrelationPlot(ABPlot): def __init__(self, parent=None): super().__init__(parent) sns.set(style="darkgrid") @@ -344,7 +274,7 @@ def plot(self, df: pd.DataFrame): self.canvas.draw() -class MonteCarloPlot(Plot): +class MonteCarloPlot(ABPlot): """Monte Carlo plot.""" def __init__(self, parent=None): diff --git a/activity_browser/ui/dialogs/uncertainty.py b/activity_browser/ui/dialogs/uncertainty.py index 699f32453..0341bda51 100644 --- a/activity_browser/ui/dialogs/uncertainty.py +++ b/activity_browser/ui/dialogs/uncertainty.py @@ -1,17 +1,18 @@ from logging import getLogger import numpy as np +import seaborn as sns + from qtpy import QtCore, QtGui, QtWidgets from qtpy.QtCore import Signal, Slot from stats_arrays import uncertainty_choices as uncertainty from stats_arrays.distributions import * -from activity_browser import actions -from ..core import application +from activity_browser import actions, application +from activity_browser.ui.widgets.plot import ABPlot from ...bwutils import PedigreeMatrix, get_uncertainty_interface from ...bwutils.uncertainty import EMPTY_UNCERTAINTY -from ..figures import SimpleDistributionPlot log = getLogger(__name__) @@ -713,3 +714,23 @@ def generate_plot(self) -> None: if not np.any(np.isnan(data)): self.plot.plot(data, median) self.enable_pedigree.emit(True) + + +class SimpleDistributionPlot(ABPlot): + def plot(self, data: np.ndarray, mean: float, label: str = "Value"): + self.reset_plot() + try: + sns.histplot(data.T, kde=True, stat="density", ax=self.ax, edgecolor="none") + except RuntimeError as e: + log.error("{}: Plotting without KDE.".format(e)) + sns.histplot( + data.T, kde=False, stat="density", ax=self.ax, edgecolor="none" + ) + self.ax.set_xlabel(label) + self.ax.set_ylabel("Probability density") + # Add vertical line at given mean of x-axis + self.ax.axvline(mean, label="Mean / amount", c="r", ymax=0.98) + self.ax.legend(loc="upper right") + _, height = self.canvas.get_width_height() + self.setMinimumHeight(height / 2) + self.canvas.draw() diff --git a/activity_browser/ui/figures.py b/activity_browser/ui/figures.py deleted file mode 100644 index 0b618fee1..000000000 --- a/activity_browser/ui/figures.py +++ /dev/null @@ -1,390 +0,0 @@ -import math -from logging import getLogger - -import numpy as np -import pandas as pd -import seaborn as sns -import bw2data as bd - -import matplotlib.pyplot as plt -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg -from matplotlib.figure import Figure -from qtpy import QtWidgets - -from activity_browser.utils import savefilepath -from activity_browser.bwutils.commontasks import wrap_text - -log = getLogger(__name__) - - -class Plot(QtWidgets.QWidget): - ALL_FILTER = "All Files (*.*)" - PNG_FILTER = "PNG (*.png)" - SVG_FILTER = "SVG (*.svg)" - - def __init__(self, parent=None): - super().__init__(parent) - # create figure, canvas, and axis - # self.figure = Figure(tight_layout=True) - self.figure = Figure(constrained_layout=True) - self.canvas = FigureCanvasQTAgg(self.figure) - self.canvas.setMinimumHeight(0) - - self.canvas.destroyed.connect(self.check) - - self.ax = self.figure.add_subplot(111) # create an axis - self.plot_name = "Figure" - - # set the layout - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.canvas) - self.setLayout(layout) - self.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding - ) - self.updateGeometry() - - def check(self): - print("WHY DELETE") - - def plot(self, *args, **kwargs): - raise NotImplementedError - - def reset_plot(self) -> None: - self.figure.clf() - self.ax = self.figure.add_subplot(111) - - def get_canvas_size_in_inches(self): - # print("Canvas size:", self.canvas.get_width_height()) - return tuple(x / self.figure.dpi for x in self.canvas.get_width_height()) - - def to_png(self): - """Export to .png format.""" - filepath = savefilepath( - default_file_name=self.plot_name, file_filter=self.PNG_FILTER - ) - if filepath: - if not filepath.endswith(".png"): - filepath += ".png" - self.figure.savefig(filepath) - - def to_svg(self): - """Export to .svg format.""" - filepath = savefilepath( - default_file_name=self.plot_name, file_filter=self.SVG_FILTER - ) - if filepath: - if not filepath.endswith(".svg"): - filepath += ".svg" - self.figure.savefig(filepath) - - -class LCAResultsBarChart(Plot): - """ " Generate a bar chart comparing the absolute LCA scores of the products""" - - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "LCA scores" - - def plot(self, df: pd.DataFrame, method: tuple, labels: list): - self.reset_plot() - height_inches, width_inches = self.get_canvas_size_in_inches() - self.figure.set_size_inches(height_inches, width_inches) - - # https://github.com/LCA-ActivityBrowser/activity-browser/issues/489 - df.index = pd.Index(labels) # Replace index of tuples - show_legend = df.shape[1] != 1 # Do not show the legend for 1 column - df.plot.barh(ax=self.ax, legend=show_legend) - self.ax.invert_yaxis() - - # labels - self.ax.set_yticks(np.arange(len(labels))) - self.ax.set_xlabel(bd.methods[method].get("unit")) - self.ax.set_title(", ".join([m for m in method])) - # self.ax.set_yticklabels(labels, minor=False) - - # grid - self.ax.grid(which="major", axis="x", color="grey", linestyle="dashed") - self.ax.set_axisbelow(True) # puts gridlines behind bars - - # draw - self.canvas.draw() - - -class LCAResultsPlot(Plot): - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "LCA heatmap" - - def plot(self, df: pd.DataFrame, invert_plot: bool = False): - """Plot a heatmap grid of the different impact categories and reference flows.""" - # need to clear the figure and add axis again - # because of the colorbar which does not get removed by the ax.clear() - self.reset_plot() - - dfp = df.copy() - dfp.index = dfp["index"] - dfp.drop( - dfp.select_dtypes(["object"]), axis=1, inplace=True - ) # get rid of all non-numeric columns (metadata) - if "amount" in dfp.columns: - dfp.drop(["amount"], axis=1, inplace=True) # Drop the 'amount' col - if "Score" in dfp.index: - dfp.drop("Score", inplace=True) - - # avoid figures getting too large horizontally - dfp.index = [wrap_text(i, max_length=40) for i in dfp.index] - dfp.columns = [wrap_text(i, max_length=20) for i in dfp.columns] - prop = dfp.divide(dfp.abs().max(axis=0)).multiply(100) - dfp.replace(np.nan, 0, inplace=True) - if invert_plot: - dfp = dfp.T - prop = prop.T - - # set different color palette depending on whether all values are positive or not - if ( - dfp.min(axis=None) < 0 and dfp.max(axis=None) > 0 - ): # has both negative AND positive values - cmap = sns.color_palette("vlag_r", as_cmap=True) - else: # has only positive OR negative values - cmap = sns.color_palette("Blues", as_cmap=True) - - sns.heatmap( - prop, - ax=self.ax, - cmap=cmap, - annot=dfp, - linewidths=0.05, - annot_kws={ - "size": 11 if dfp.shape[1] <= 8 else 9, - "rotation": 0 if dfp.shape[1] <= 8 else 60, - }, - cbar_kws={"format": "%.0f%%"}, - ) - self.ax.tick_params(labelsize=8) - if dfp.shape[1] > 5: - self.ax.set_xticklabels(self.ax.get_xticklabels(), rotation="vertical") - self.ax.set_yticklabels(self.ax.get_yticklabels(), rotation="horizontal") - - # refresh canvas - size_inches = (2 + dfp.shape[0] * 0.5, 4 + dfp.shape[0] * 0.55) - self.figure.set_size_inches(self.get_canvas_size_in_inches()[0], size_inches[1]) - size_pixels = self.figure.get_size_inches() * self.figure.dpi - self.setMinimumHeight(size_pixels[1]) - - self.canvas.draw() - - -class ContributionPlot(Plot): - MAX_LEGEND = 30 - - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "Contributions" - self.parent = parent - - def plot(self, df: pd.DataFrame, unit: str = None): - """Plot a horizontal stacked bar chart of contributions, - add 'total' marker if both positive and negative results are present.""" - dfp = df.copy() - dfp = dfp.iloc[:, ::-1] # reverse column names so they align with calculation setup and rest of results - - dfp.index = dfp["index"] - dfp.drop( - dfp.select_dtypes(["object"]), axis=1, inplace=True - ) # get rid of all non-numeric columns (metadata) - if "Score" in dfp.index: - dfp.drop("Score", inplace=True) - # drop rows if all values are 0 - dfp = dfp.loc[~(dfp == 0).all(axis=1)] - - self.ax.clear() - canvas_width_inches, canvas_height_inches = self.get_canvas_size_in_inches() - optimal_height_inches = 4 + dfp.shape[1] * 0.55 - # print('Optimal Contribution plot height:', optimal_height_inches) - self.figure.set_size_inches(canvas_width_inches, optimal_height_inches) - - # avoid figures getting too large horizontally - dfp.index = pd.Index([wrap_text(str(i), max_length=40) for i in dfp.index]) - dfp.columns = pd.Index([wrap_text(i, max_length=40) for i in dfp.columns]) - # Strip invalid characters from the ends of row/column headers - dfp.index = dfp.index.str.strip("_ \n\t") - dfp.columns = dfp.columns.str.strip("_ \n\t") - - # set colormap to use - items = dfp.shape[0] # how many contribution items - # skip grey and black at start/end of cmap - cmap = plt.cm.nipy_spectral_r(np.linspace(0, 1, items + 2))[1:-1] - colors = {item: color for item, color in zip(dfp.index, cmap)} - # overwrite rest values to grey - colors["Rest (+)"] = [0.8, 0.8, 0.8, 1.] - colors["Rest (-)"] = [0.8, 0.8, 0.8, 1.] - - dfp.T.plot.barh( - stacked=True, - color=colors, - ax=self.ax, - legend=False if dfp.shape[0] >= self.MAX_LEGEND else True, - ) - self.ax.tick_params(labelsize=8) - if unit: - self.ax.set_xlabel(unit) - - # show legend if not too many items - if not dfp.shape[0] >= self.MAX_LEGEND: - plt.rc("legend", **{"fontsize": 8}) - ncols = math.ceil(dfp.shape[0] * 0.6 / optimal_height_inches) - # print('Ncols:', ncols, dfp.shape[0] * 0.55, optimal_height_inches) - self.ax.legend(loc="center left", bbox_to_anchor=(1, 0.5), ncol=ncols) - - # grid - self.ax.grid(which="major", axis="x", color="grey", linestyle="dashed") - self.ax.set_axisbelow(True) # puts gridlines behind bars - # make the zero line more present - grid = self.ax.get_xgridlines() - # get the 0 line from all gridlines - label_pos = [i for i, label in enumerate(self.ax.get_xticklabels()) if label.get_position()[0] == 0.0] - if len(label_pos) > 0: - zero_line = grid[label_pos[0]] - zero_line.set_color("black") - zero_line.set_linestyle("solid") - - # total marker when enabled and both negative and positive results are present in a column - if self.parent.score_marker: - marker_size = max(min(150 / dfp.shape[1], 35), 10) # set marker size dynamic between 10 - 35 - for i, col in enumerate(dfp): - total = np.sum(dfp[col]) - abs_total = np.sum(np.abs(dfp[col])) - if abs(total) != abs_total: - self.ax.plot(total, i, - markersize=marker_size, marker="d", fillstyle="left", - markerfacecolor="black", markerfacecoloralt="grey", markeredgecolor="white") - - # TODO review: remove or enable - - # refresh canvas - # size_inches = (2 + dfp.shape[0] * 0.5, 4 + dfp.shape[1] * 0.55) - # self.figure.set_size_inches(self.get_canvas_size_in_inches()[0], size_inches[1]) - - size_pixels = self.figure.get_size_inches() * self.figure.dpi - self.setMinimumHeight(size_pixels[1]) - self.canvas.draw() - - -class CorrelationPlot(Plot): - def __init__(self, parent=None): - super().__init__(parent) - sns.set(style="darkgrid") - - def plot(self, df: pd.DataFrame): - """Plot a heatmap of correlations between different reference flows.""" - # need to clear the figure and add axis again - # because of the colorbar which does not get removed by the ax.clear() - self.reset_plot() - canvas_size = self.canvas.get_width_height() - # print("Canvas size:", canvas_size) - size = (4 + df.shape[1] * 0.3, 4 + df.shape[1] * 0.3) - self.figure.set_size_inches(size[0], size[1]) - - corr = df.corr() - # Generate a mask for the upper triangle - mask = np.zeros_like(corr, dtype=bool) - mask[np.triu_indices_from(mask)] = True - # Draw the heatmap with the mask and correct aspect ratio - vmax = np.abs(corr.values[~mask]).max() - # vmax = np.abs(corr).max() - sns.heatmap( - corr, - mask=mask, - cmap=plt.cm.PuOr, - vmin=-vmax, - vmax=vmax, - square=True, - linecolor="lightgray", - linewidths=1, - ax=self.ax, - ) - - df_lte8_cols = df.shape[1] <= 8 - for i in range(len(corr)): - self.ax.text( - i + 0.5, - i + 0.5, - corr.columns[i], - ha="center", - va="center", - rotation=0 if df_lte8_cols else 45, - size=11 if df_lte8_cols else 9, - ) - for j in range(i + 1, len(corr)): - s = "{:.3f}".format(corr.values[i, j]) - self.ax.text( - j + 0.5, - i + 0.5, - s, - ha="center", - va="center", - rotation=0 if df_lte8_cols else 45, - size=11 if df_lte8_cols else 9, - ) - self.ax.axis("off") - - # refresh canvas - size_pixels = self.figure.get_size_inches() * self.figure.dpi - self.setMinimumHeight(size_pixels[1]) - self.canvas.draw() - - -class MonteCarloPlot(Plot): - """Monte Carlo plot.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "Monte Carlo" - - def plot(self, df: pd.DataFrame, method: tuple): - self.ax.clear() - - for col in df.columns: - color = self.ax._get_lines.get_next_color() - df[col].hist( - ax=self.ax, - figure=self.figure, - label=col, - density=True, - color=color, - alpha=0.5, - ) # , histtype="step") - # self.ax.axvline(df[col].median(), color=color) - self.ax.axvline(df[col].mean(), color=color) - - self.ax.set_xlabel(bd.methods[method]["unit"]) - self.ax.set_ylabel("Probability") - self.ax.legend( - loc="upper center", - bbox_to_anchor=(0.5, -0.07), - ) # ncol=2 - - # lconfi, upconfi =mc['statistics']['interval'][0], mc['statistics']['interval'][1] - - self.canvas.draw() - - -class SimpleDistributionPlot(Plot): - def plot(self, data: np.ndarray, mean: float, label: str = "Value"): - self.reset_plot() - try: - sns.histplot(data.T, kde=True, stat="density", ax=self.ax, edgecolor="none") - except RuntimeError as e: - log.error("{}: Plotting without KDE.".format(e)) - sns.histplot( - data.T, kde=False, stat="density", ax=self.ax, edgecolor="none" - ) - self.ax.set_xlabel(label) - self.ax.set_ylabel("Probability density") - # Add vertical line at given mean of x-axis - self.ax.axvline(mean, label="Mean / amount", c="r", ymax=0.98) - self.ax.legend(loc="upper right") - _, height = self.canvas.get_width_height() - self.setMinimumHeight(height / 2) - self.canvas.draw() \ No newline at end of file diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index 8b8afef15..b60325b40 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -24,3 +24,4 @@ from ..dialogs.list_edit_dialog import ABListEditDialog from .drop_overlay import ABDropOverlay from ..dialogs.database_selection_dialog import ABDatabaseSelectionDialog +from .plot import ABPlot diff --git a/activity_browser/ui/widgets/plot.py b/activity_browser/ui/widgets/plot.py new file mode 100644 index 000000000..1e665dc08 --- /dev/null +++ b/activity_browser/ui/widgets/plot.py @@ -0,0 +1,65 @@ +from logging import getLogger + +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg +from matplotlib.figure import Figure +from qtpy import QtWidgets + +from activity_browser.utils import savefilepath + +log = getLogger(__name__) + + +class ABPlot(QtWidgets.QWidget): + ALL_FILTER = "All Files (*.*)" + PNG_FILTER = "PNG (*.png)" + SVG_FILTER = "SVG (*.svg)" + + def __init__(self, parent=None): + super().__init__(parent) + # create figure, canvas, and axis + self.figure = Figure(constrained_layout=True) + self.canvas = FigureCanvasQTAgg(self.figure) + self.canvas.setMinimumHeight(0) + + self.ax = self.figure.add_subplot(111) # create an axis + self.plot_name = "Figure" + + # set the layout + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.canvas) + self.setLayout(layout) + self.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + self.updateGeometry() + + def plot(self, *args, **kwargs): + raise NotImplementedError + + def reset_plot(self) -> None: + self.figure.clf() + self.ax = self.figure.add_subplot(111) + + def get_canvas_size_in_inches(self): + return tuple(x / self.figure.dpi for x in self.canvas.get_width_height()) + + def to_png(self): + """Export to .png format.""" + filepath = savefilepath( + default_file_name=self.plot_name, file_filter=self.PNG_FILTER + ) + if filepath: + if not filepath.endswith(".png"): + filepath += ".png" + self.figure.savefig(filepath) + + def to_svg(self): + """Export to .svg format.""" + filepath = savefilepath( + default_file_name=self.plot_name, file_filter=self.SVG_FILTER + ) + if filepath: + if not filepath.endswith(".svg"): + filepath += ".svg" + self.figure.savefig(filepath) + From ca25ea1f5e83e20cb20bfba5e4033fa995df6dcd Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sun, 26 Oct 2025 13:53:06 +0100 Subject: [PATCH 051/267] Refactor import statements to replace references to UncertaintyWizard from wizards to dialogs --- .../exchange/exchange_uncertainty_modify.py | 2 +- .../actions/method/cf_uncertainty_modify.py | 2 +- .../pages/parameters/parameter_models.py | 2 +- activity_browser/ui/dialogs/__init__.py | 7 + activity_browser/ui/dialogs/dialog.py | 572 ------------------ activity_browser/ui/widgets/__init__.py | 3 - tests/actions/test_exchange_actions.py | 2 +- tests/actions/test_method_actions.py | 2 +- 8 files changed, 12 insertions(+), 580 deletions(-) create mode 100644 activity_browser/ui/dialogs/__init__.py delete mode 100644 activity_browser/ui/dialogs/dialog.py diff --git a/activity_browser/actions/exchange/exchange_uncertainty_modify.py b/activity_browser/actions/exchange/exchange_uncertainty_modify.py index f938fb083..3da988b2a 100644 --- a/activity_browser/actions/exchange/exchange_uncertainty_modify.py +++ b/activity_browser/actions/exchange/exchange_uncertainty_modify.py @@ -3,7 +3,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -from activity_browser.ui.wizards import UncertaintyWizard +from activity_browser.ui.dialogs import UncertaintyWizard class ExchangeUncertaintyModify(ABAction): diff --git a/activity_browser/actions/method/cf_uncertainty_modify.py b/activity_browser/actions/method/cf_uncertainty_modify.py index 16d6bc735..564c96eb7 100644 --- a/activity_browser/actions/method/cf_uncertainty_modify.py +++ b/activity_browser/actions/method/cf_uncertainty_modify.py @@ -5,7 +5,7 @@ from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons -from activity_browser.ui.wizards import UncertaintyWizard +from activity_browser.ui.dialogs import UncertaintyWizard class CFUncertaintyModify(ABAction): diff --git a/activity_browser/layouts/pages/parameters/parameter_models.py b/activity_browser/layouts/pages/parameters/parameter_models.py index 1ecb44e31..9277defe5 100644 --- a/activity_browser/layouts/pages/parameters/parameter_models.py +++ b/activity_browser/layouts/pages/parameters/parameter_models.py @@ -14,7 +14,7 @@ from activity_browser import actions, signals, application, bwutils from activity_browser.mod import bw2data as bd -from activity_browser.ui.wizards import UncertaintyWizard +from activity_browser.ui.dialogs import UncertaintyWizard from .base import BaseTreeModel, EditablePandasModel, TreeItem, PandasModel diff --git a/activity_browser/ui/dialogs/__init__.py b/activity_browser/ui/dialogs/__init__.py new file mode 100644 index 000000000..c9a7650e2 --- /dev/null +++ b/activity_browser/ui/dialogs/__init__.py @@ -0,0 +1,7 @@ +from .database_selection_dialog import ABDatabaseSelectionDialog +from .list_edit_dialog import ABListEditDialog +from .progress_dialog import ABProgressDialog +from .uncertainty import UncertaintyWizard +from .new_node_dialog import NewNodeDialog +from .progress_dialog import ABProgressDialog + diff --git a/activity_browser/ui/dialogs/dialog.py b/activity_browser/ui/dialogs/dialog.py deleted file mode 100644 index b98d714fe..000000000 --- a/activity_browser/ui/dialogs/dialog.py +++ /dev/null @@ -1,572 +0,0 @@ -from logging import getLogger - -from qtpy import QtGui, QtWidgets -from qtpy.QtCore import Qt -from activity_browser.ui import widgets, icons - -log = getLogger(__name__) - - -class FilterManagerDialog(QtWidgets.QDialog): - """Set filters for a table. - - Dialog has 1 tab per given column. Each tab has rows for filters, - where type/query/other is defined. User can add/remove filters as desired. - When multiple filters exist for 1 column, user can choose AND/OR combination of filters. - AND/OR for combining columns can also be chosen. - - Required inputs: - - column names: dict --> the column names and their indices in the table - format: {'col_name': i} - Optional inputs: - - filters: dict --> pre-apply filters in the dialog (see format example below) - - selected_column: int --> open the dialog with this column tab open - - column_types: dict --> show other filters for this column - format: {'col_name': 'num'} - options: str/num, defaults to str if no type is given - - Interaction: - - call 'start_filter_dialog' of 'ABFilterableDataFrameView' to launch dialog, - filters are only applied when OK is selected. This calls self.get_filters, - which returns filter data as dict. - - example of filters (see also ABMultiColumnSortProxyModel): - filters = { - 0: {'filters': [('contains', 'heat', False), ('contains', 'electricity', False)], - 'mode': 'OR'}, - 1: {'filters': [('contains', 'market', False)]} - } - """ - - def __init__( - self, - column_names: dict, - filter_types: dict, - filters: dict = None, - selected_column: int = 0, - column_types: dict = {}, - parent=None, - ): - super().__init__(parent) - self.setWindowIcon(icons.qicons.filter) - self.setWindowTitle("Manage table filters") - - # set given filters, if any - if isinstance(filters, dict): - self.filters = filters - else: - self.filters = {} - - # create a tab for every column in the table - self.tab_widget = QtWidgets.QTabWidget() - self.tabs = [] - - # we need this dict as we may have hidden columns (e.g. CFTable) - self.col_id_2_tab_id = {} - for tab_id, col_data in enumerate(column_names.items()): - col_name, col_id = col_data - self.col_id_2_tab_id[col_id] = tab_id - tab = ColumnFilterTab( - parent=self, - state=self.filters.get(col_id, None), - col_type=column_types.get(col_name, "str"), - filter_types=filter_types, - ) - self.tabs.append(tab) - self.tab_widget.addTab(tab, col_name) - - # add AND/OR choice button. - self.and_or_buttons = AndOrRadioButtons(label_text="Combine columns:") - # in the extremely unlikely event there is only 1 column, hide the AND/OR option. - if len(column_names) == 1: - self.and_or_buttons.hide() - - # create OK/cancel buttons - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - # assemble layout - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.tab_widget) - layout.addWidget(self.and_or_buttons) - layout.addWidget(self.buttons) - self.setLayout(layout) - - # set the column that launched the dialog as the open tab - self.tab_widget.setCurrentIndex(self.col_id_2_tab_id[selected_column]) - self.tabs[selected_column].filter_rows[-1].filter_query_line.setFocus() - - @property - def get_filters(self) -> dict: - state = {} - t2c = {v: k for k, v in self.col_id_2_tab_id.items()} - for tab_id, tab in enumerate(self.tabs): - tab_state = tab.get_state - if isinstance(tab_state, dict): - state[t2c[tab_id]] = tab_state - if len(state) == 0: - return - state["mode"] = self.and_or_buttons.get_state - return state - - -class SimpleFilterDialog(QtWidgets.QDialog): - """Add one filter to a column. - - Related to FilterManagerDialog. - """ - - def __init__( - self, - column_name: dict, - filter_types: dict, - column_type: str = "str", - preset_type: str = None, - parent=None, - ): - super().__init__(parent) - self.setWindowIcon(icons.qicons.filter) - self.setWindowTitle("Add filter") - - # Create filter label and buttons - label = QtWidgets.QLabel("Define a filter for column '{}'".format(column_name)) - - if column_type == "num": - self.filter_row = NumFilterRow( - idx=0, - filter_types=filter_types, - remove_option=False, - preset_type=preset_type, - parent=self, - ) - else: - # if none of the above types, assume str - self.filter_row = StrFilterRow( - idx=0, - filter_types=filter_types, - remove_option=False, - preset_type=preset_type, - parent=self, - ) - - self.filter_row.filter_query_line.setFocus() - - # create OK/cancel buttons - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(label) - layout.addWidget(self.filter_row) - layout.addWidget(self.buttons) - self.setLayout(layout) - - @property - def get_filter(self) -> tuple: - if self.filter_row.get_state: - return self.filter_row.get_state - - -class ColumnFilterTab(QtWidgets.QWidget): - """Content of column tab. - - Required inputs: - - None - Optional inputs: - - col_type: str --> the type of column, either 'str' or 'num'. defines the search type options. - defaults to 'str' - - state: dict --> dict of existing filter state that should be re-created in UI. - - Interaction: - - def get_state: Provides the state of all relevant filter elements (filter rows, AND/OR menu) - returns: dict - - def set_state: Writes given state dict to UI elements (filter rows, AND/OR menu) - """ - - def __init__( - self, filter_types: dict, col_type: str = "str", state: dict = {}, parent=None - ): - super().__init__(parent) - self.filter_types = filter_types - self.col_type = col_type - - self.add = QtWidgets.QToolButton() - self.add.setIcon(icons.qicons.add) - self.add.setToolTip("Add a new filter for this column") - self.add.clicked.connect(self.add_row) - - self.and_or_buttons = AndOrRadioButtons( - label_text="Combine filters within column:" - ) - if self.col_type == "str": - self.and_or_buttons.set_state("OR") - - self.filter_rows = [] - self.filter_widget_layout = QtWidgets.QVBoxLayout() - self.filter_widget = QtWidgets.QWidget() - self.filter_widget.setLayout(self.filter_widget_layout) - - # set the state, adds 1 empty row if state=={} - self.set_state(state) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.filter_widget) - layout.addWidget(self.add) - layout.addStretch() - layout.addWidget(self.and_or_buttons) - self.setLayout(layout) - - def add_row(self, state: tuple = None) -> None: - """Add a new row to the self.filter_rows.""" - idx = len(self.filter_rows) - - if self.col_type == "num": - new_filter_row = NumFilterRow( - idx=idx, state=state, filter_types=self.filter_types, parent=self - ) - else: - # if none of the above types, assume str - new_filter_row = StrFilterRow( - idx=idx, state=state, filter_types=self.filter_types, parent=self - ) - - self.filter_rows.append(new_filter_row) - self.filter_widget_layout.addWidget(new_filter_row) - self.show_hide_and_or() - - def remove_row(self, idx: int) -> None: - """Remove the row from the setup""" - # remove the row from widget and self.filter_rows - self.filter_widget_layout.itemAt(idx).widget().deleteLater() - self.filter_rows.pop(idx) - # re-index the list of rows - for i, filter_row in enumerate(self.filter_rows): - filter_row.idx = i - # if there would be no remaining rows, add a new empty one - if len(self.filter_rows) == 0: - self.add_row() - self.show_hide_and_or() - - @property - def get_state(self) -> dict: - # check if there are filters - if len(self.filter_rows) == 0: - return None - # check if there are valid filters - valid_filters = [row.get_state for row in self.filter_rows if row.get_state] - if len(valid_filters) == 0: - return None - elif len(valid_filters) == 1: - return {"filters": valid_filters} - else: - return {"filters": valid_filters, "mode": self.and_or_buttons.get_state} - - def set_state(self, state: dict) -> None: - if not state: - self.add_row() - self.and_or_buttons.hide() - return - - # add one row per filter - filters = state["filters"] - self.filter_rows = [] - for filter_state in filters: - self.add_row(filter_state) - - # set state and show/hide the AND/OR widget - self.show_hide_and_or() - if state.get("mode", False): - self.and_or_buttons.set_state(state["mode"]) - - def show_hide_and_or(self) -> None: - if len(self.filter_rows) > 1: - self.and_or_buttons.show() - else: - self.and_or_buttons.hide() - - -class FilterRow(QtWidgets.QWidget): - """Convenience class for managing a filter input row. - - This class is purely intended for FilterManagerDialog and related, take this into account if using elsewhere. - - Required inputs: - - idx: int --> integer index in self.filter_rows of parent. Used as ID in parent - idx is the index position of this FilterRow in the list of rows in parent. - - filter_types: dict --> the types of filter available - Optional inputs: - - state: tuple --> tuple of existing filter state that should be re-created in UI. - - Interaction: - - def get_state: Provides the state of all relevant filter fields (filter type, query, case sensitive) - returns: tuple - - def set_state: Writes given state tuple to UI elements (filter type, query, case sensitive) - """ - - def __init__( - self, - idx: int, - filter_types: dict, - remove_option: bool = True, - preset_type: str = None, - parent=None, - ): - super().__init__(parent) - - self.idx = idx - self.filter_types = filter_types - self.filter_type = self.filter_types[self.column_type] - self.parent = parent - - self.row_layout = QtWidgets.QHBoxLayout() - - # create a 'filter type' combobox - self.filter_type_box = QtWidgets.QComboBox() - self.filter_type_box.addItems(self.filter_type) - # set a preset type if given - if isinstance(preset_type, str): - self.filter_type_box.setCurrentIndex(self.filter_type.index(preset_type)) - # add tooltip for every type option - for i, tt in enumerate(self.filter_types[self.column_type + "_tt"]): - self.filter_type_box.setItemData(i, tt, Qt.ToolTipRole) - - # create the filter input line - self.filter_query_line = QtWidgets.QLineEdit() - self.filter_query_line.setFocusPolicy(Qt.StrongFocus) - - if remove_option: - # add buttons to remove the row - self.remove = QtWidgets.QToolButton() - self.remove.setIcon(icons.qicons.delete) - self.remove.setToolTip("Remove this filter") - self.remove.clicked.connect(self.self_destruct) - - @property - def get_state(self) -> tuple: - raise NotImplementedError - - def set_state(self, state: tuple) -> None: - raise NotImplementedError - - def set_input_changes(self) -> None: - raise NotImplementedError - - def self_destruct(self) -> None: - """Remove this FilterRow object from parent.""" - self.parent.remove_row(self.idx) - - -class StrFilterRow(FilterRow): - """Convenience class for managing a filter input row for 'str' type.""" - - def __init__( - self, - idx: int, - filter_types: dict, - state: tuple = None, - remove_option: bool = True, - preset_type: str = None, - parent=None, - ): - - self.column_type = "str" - super().__init__(idx, filter_types, remove_option, preset_type, parent) - - # create case-sensitive box - self.case_sensitive_text = QtWidgets.QLabel("Case Sensitive:") - self.filter_case_sensitive_check = QtWidgets.QCheckBox() - - # assemble the layout - self.row_layout.addWidget(self.filter_type_box) - self.row_layout.addWidget(self.filter_query_line) - self.row_layout.addWidget(self.case_sensitive_text) - self.row_layout.addWidget(self.filter_case_sensitive_check) - if remove_option: - # add button to remove the row - self.row_layout.addWidget(widgets.ABVLine(self)) - self.row_layout.addWidget(self.remove) - - self.setLayout(self.row_layout) - - # set the state if one was given - if isinstance(state, tuple): - self.set_state(state) - - self.filter_type_box.currentIndexChanged.connect(self.set_input_changes) - self.set_input_changes() - - @property - def get_state(self) -> tuple: - # remove weird whitespace from input - query_line = ( - self.filter_query_line.text() - .translate(str.maketrans("", "", "\n\t\r")) - .strip() - ) - # if valid, return a tuple with the state, otherwise, return None - if query_line == "": - return None - - selected_type = self.filter_type_box.currentText() - selected_query = self.filter_query_line.text() - case_sensitive = self.filter_case_sensitive_check.isChecked() - return selected_type, selected_query, case_sensitive - - def set_state(self, state: tuple) -> None: - selected_type, selected_query, case_sensitive = state - self.filter_type_box.setCurrentIndex(self.filter_type.index(selected_type)) - self.filter_query_line.setText(selected_query) - self.filter_case_sensitive_check.setChecked(case_sensitive) - - def set_input_changes(self) -> None: - # set tooltip to currently selected item - tt = self.filter_types[self.column_type + "_tt"][ - self.filter_type_box.currentIndex() - ] - self.filter_type_box.setToolTip(tt) - - -class NumFilterRow(FilterRow): - """Convenience class for managing a filter input row for 'num' type.""" - - def __init__( - self, - idx: int, - filter_types: dict, - state: tuple = None, - remove_option: bool = True, - preset_type: str = None, - parent=None, - ): - - self.column_type = "num" - super().__init__(idx, filter_types, remove_option, preset_type, parent) - - # add an input line in case 'between' ('<= x <=') is selected - self.filter_query_line0 = QtWidgets.QLineEdit() - self.filter_query_line0.hide() - - # set 'double' validator for input lines - self.filter_query_line0.setValidator(QtGui.QDoubleValidator()) - self.filter_query_line.setValidator(QtGui.QDoubleValidator()) - - # assemble the layout - self.row_layout.addWidget(self.filter_query_line0) - self.row_layout.addWidget(self.filter_type_box) - self.row_layout.addWidget(self.filter_query_line) - if remove_option: - # add button to remove the row - self.row_layout.addWidget(widgets.ABVLine(self)) - self.row_layout.addWidget(self.remove) - - self.setLayout(self.row_layout) - - # set the state if one was given - if isinstance(state, tuple): - self.set_state(state) - - self.filter_type_box.currentIndexChanged.connect(self.set_input_changes) - self.set_input_changes() - - @property - def get_state(self) -> tuple: - # remove weird whitespace from input - query_line = ( - self.filter_query_line.text() - .translate(str.maketrans("", "", " \n\t\r")) - .strip() - ) - # if valid, return a tuple with the state, otherwise, return None - if query_line == "": - return None - - selected_type = self.filter_type_box.currentText() - selected_query = self.filter_query_line.text() - if self.filter_type_box.currentText() == "<= x <=": - selected_query = ( - self.filter_query_line0.text(), - self.filter_query_line.text(), - ) - return selected_type, selected_query - - def set_state(self, state: tuple) -> None: - selected_type, selected_query = state - self.set_input_changes() - self.filter_type_box.setCurrentIndex(self.filter_type.index(selected_type)) - if selected_type == "<= x <=": - self.filter_query_line0.setText(selected_query[0]) - self.filter_query_line.setText(selected_query[1]) - else: - self.filter_query_line.setText(selected_query) - - def set_input_changes(self) -> None: - # enable whether the extra input line is visible - if self.filter_type_box.currentText() == "<= x <=": - self.filter_query_line0.show() - else: - self.filter_query_line0.hide() - # set tooltip to currently selected item - tt = self.filter_types[self.column_type + "_tt"][ - self.filter_type_box.currentIndex() - ] - self.filter_type_box.setToolTip(tt) - - -class AndOrRadioButtons(QtWidgets.QWidget): - """Convenience class for managing AND/OR buttons. - - This class is purely intended for FilterManagerDialog and related, take this into account if using elsewhere. - - Required inputs: - - None - Optional inputs: - - label_text: str --> - - state: str --> str of existing AND/OR state that should be re-created in UI. - - Interaction: - - def get_state: Provides the state of AND/OR radio buttons (string of 'AND' or 'OR') - returns: str - - def set_state: Writes given AND/OR state UI element (string of 'AND' or 'OR') - """ - - def __init__(self, label_text: str = "", state: str = None, parent=None): - super().__init__(parent) - # create an AND/OR widget - layout = QtWidgets.QHBoxLayout() - self.btn_group = QtWidgets.QButtonGroup() - self.AND = QtWidgets.QRadioButton("AND") - self.OR = QtWidgets.QRadioButton("OR") - self.btn_group.addButton(self.AND) - self.btn_group.addButton(self.OR) - layout.addStretch() - layout.addWidget(QtWidgets.QLabel(label_text)) - layout.addWidget(self.AND) - layout.addWidget(self.OR) - self.setLayout(layout) - self.setToolTip( - "Choose how filters combine with each other.\n" - "AND must satisfy all filters, OR must satisfy at least one filter." - ) - - # set the state if one was given, otherwise, assume AND - if isinstance(state, str): - self.set_state(state) - else: - self.set_state("AND") - - @property - def get_state(self) -> str: - return self.btn_group.checkedButton().text() - - def set_state(self, state: str) -> None: - x = True - if state == "OR": - x = False - self.AND.setChecked(x) - self.OR.setChecked(not x) diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index b60325b40..4ccdf99bd 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -8,7 +8,6 @@ from .item import ABAbstractItem, ABBranchItem, ABDataItem from .line import ABHLine, ABVLine from .formula_edit import ABFormulaEdit -from ..dialogs.progress_dialog import ABProgressDialog from .combobox import ABComboBox from .button_collapser import ABRadioButtonCollapser @@ -21,7 +20,5 @@ from .main_window import MainWindow from .central import CentralTabWidget from .menu import ABMenu -from ..dialogs.list_edit_dialog import ABListEditDialog from .drop_overlay import ABDropOverlay -from ..dialogs.database_selection_dialog import ABDatabaseSelectionDialog from .plot import ABPlot diff --git a/tests/actions/test_exchange_actions.py b/tests/actions/test_exchange_actions.py index 632fff067..8115099ee 100644 --- a/tests/actions/test_exchange_actions.py +++ b/tests/actions/test_exchange_actions.py @@ -2,7 +2,7 @@ from stats_arrays.distributions import NoUncertainty, UndefinedUncertainty from activity_browser import actions, application -from activity_browser.ui.wizards import UncertaintyWizard +from activity_browser.ui.dialogs import UncertaintyWizard # def test_exchange_copy_sdf(basic_database): diff --git a/tests/actions/test_method_actions.py b/tests/actions/test_method_actions.py index 284d94b93..bd3641854 100644 --- a/tests/actions/test_method_actions.py +++ b/tests/actions/test_method_actions.py @@ -9,7 +9,7 @@ ) from activity_browser import actions -from activity_browser.ui.wizards import UncertaintyWizard +from activity_browser.ui.dialogs import UncertaintyWizard def test_cf_amount_modify(basic_database): From 5a649911e978e4b58e3203ff9ef80cff13e596d3 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sun, 26 Oct 2025 21:05:42 +0100 Subject: [PATCH 052/267] First iteration of uncertainty dialog --- .../ui/dialogs/uncertainty_dialog.py | 464 ++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 activity_browser/ui/dialogs/uncertainty_dialog.py diff --git a/activity_browser/ui/dialogs/uncertainty_dialog.py b/activity_browser/ui/dialogs/uncertainty_dialog.py new file mode 100644 index 000000000..9a8fb83d5 --- /dev/null +++ b/activity_browser/ui/dialogs/uncertainty_dialog.py @@ -0,0 +1,464 @@ +from __future__ import annotations + +from logging import getLogger +from typing import Optional, Tuple + +import numpy as np +import seaborn as sns + +from qtpy import QtCore, QtGui, QtWidgets +from stats_arrays import uncertainty_choices as uncertainty +from stats_arrays.distributions import * # noqa: F401,F403 - mirror wizard usage + +from ...ui.widgets.plot import ABPlot +from ...bwutils.uncertainty import EMPTY_UNCERTAINTY + +log = getLogger(__name__) + + +class UncertaintyDialog(QtWidgets.QDialog): + """Single-step dialog for defining a stats_arrays uncertainty. + + Mirrors the behavior of the UncertaintyWizard type page but returns a + stats_arrays structured array on accept. + + Usage: + ok, array = UncertaintyDialog.get_uncertainty(parent, initial=dict(...)) + if ok: + # array is a numpy structured array compatible with stats_arrays + """ + + def __init__(self, parent=None, initial: Optional[dict] = None): + super().__init__(parent) + self.setWindowTitle("Set Uncertainty") + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + # State + self.dist = None + self.result_array = None # Filled on accept + self.previous_dist_id: Optional[int] = None + self.mean_is_calculated = { + TriangularUncertainty.id, + UniformUncertainty.id, + DiscreteUniform.id, + BetaUncertainty.id, + } + + # Top: distribution selection + box1 = QtWidgets.QGroupBox("Select the uncertainty distribution") + self.distribution = QtWidgets.QComboBox(box1) + self.distribution.addItems([ud.description for ud in uncertainty.choices]) + self.distribution.currentIndexChanged.connect(self._on_distribution_changed) + + header_layout = QtWidgets.QGridLayout() + header_layout.addWidget(QtWidgets.QLabel("Distribution:"), 0, 0) + header_layout.addWidget(self.distribution, 0, 1) + box1.setLayout(header_layout) + + # Middle: parameters + self.fields_box = QtWidgets.QGroupBox("Fill out required parameters") + self.locale = QtCore.QLocale( + QtCore.QLocale.English, QtCore.QLocale.UnitedStates + ) + self.locale.setNumberOptions(QtCore.QLocale.RejectGroupSeparator) + self.validator = QtGui.QDoubleValidator() + self.validator.setLocale(self.locale) + + # loc/mean + self.loc_label = QtWidgets.QLabel("Loc:") + self.loc = QtWidgets.QLineEdit() + self.loc.setValidator(self.validator) + self.loc.textEdited.connect(self._sync_mean_from_loc) + self.loc.textEdited.connect(self._check_negative) + self.loc.textEdited.connect(self._generate_plot) + + self.mean_label = QtWidgets.QLabel("Mean:") + self.mean = QtWidgets.QLineEdit() + self.mean.setValidator(self.validator) + self.mean.textEdited.connect(self._sync_loc_from_mean) + self.mean.textEdited.connect(self._check_negative) + self.mean.textEdited.connect(self._generate_plot) + + # Calculated mean (read-only) for some dists + self.calc_mean_label = QtWidgets.QLabel("Mean:") + self.calc_mean = QtWidgets.QLineEdit("nan") + self.calc_mean.setDisabled(True) + + # Other parameters + self.scale_label = QtWidgets.QLabel("Sigma/scale:") + self.scale = QtWidgets.QLineEdit() + self.scale.setValidator(self.validator) + self.scale.textEdited.connect(self._generate_plot) + + self.shape_label = QtWidgets.QLabel("Shape:") + self.shape = QtWidgets.QLineEdit() + self.shape.setValidator(self.validator) + self.shape.textEdited.connect(self._generate_plot) + + self.min_label = QtWidgets.QLabel("Minimum:") + self.minimum = QtWidgets.QLineEdit() + self.minimum.setValidator(self.validator) + self.minimum.textEdited.connect(self._generate_plot) + + self.max_label = QtWidgets.QLabel("Maximum:") + self.maximum = QtWidgets.QLineEdit() + self.maximum.setValidator(self.validator) + self.maximum.textEdited.connect(self._generate_plot) + + # Hidden flag for negative mean on lognormal + self.negative = QtWidgets.QRadioButton(self) + self.negative.setChecked(False) + self.negative.setHidden(True) + + params_layout = QtWidgets.QGridLayout() + # row 0: read-only calculated mean (will be hidden for most dists) + params_layout.addWidget(self.calc_mean_label, 0, 0) + params_layout.addWidget(self.calc_mean, 0, 1) + # row 1: loc/mean pair + params_layout.addWidget(self.loc_label, 1, 0) + params_layout.addWidget(self.loc, 1, 1) + params_layout.addWidget(self.mean_label, 1, 3) + params_layout.addWidget(self.mean, 1, 4) + # row 2+: other params + params_layout.addWidget(self.scale_label, 2, 0) + params_layout.addWidget(self.scale, 2, 1) + params_layout.addWidget(self.shape_label, 3, 0) + params_layout.addWidget(self.shape, 3, 1) + params_layout.addWidget(self.min_label, 4, 0) + params_layout.addWidget(self.minimum, 4, 1) + params_layout.addWidget(self.max_label, 5, 0) + params_layout.addWidget(self.maximum, 5, 1) + self.fields_box.setLayout(params_layout) + + # Bottom: plot + self.plot = SimpleDistributionPlot(self) + + # Buttons + self.buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + self.buttons.accepted.connect(self._on_accept) + self.buttons.rejected.connect(self.reject) + + # Layout + layout = QtWidgets.QVBoxLayout() + layout.addWidget(box1) + layout.addWidget(self.fields_box) + layout.addWidget(self.plot) + layout.addWidget(self.buttons) + self.setLayout(layout) + + # Initialize values (defaults or provided initial) + self._apply_initial(initial or {}) + self._on_distribution_changed(self.distribution.currentIndex()) + self._sync_mean_from_loc() + self._generate_plot() + + # ---------- Public API ---------- + @staticmethod + def get_uncertainty( + parent=None, initial: Optional[dict] = None + ) -> Tuple[bool, Optional[np.ndarray]]: + dlg = UncertaintyDialog(parent, initial=initial) + ok = dlg.exec_() == QtWidgets.QDialog.Accepted + return ok, dlg.result_array if ok else None + + # ---------- Internal helpers ---------- + def _apply_initial(self, initial: dict) -> None: + # Use EMPTY_UNCERTAINTY defaults, overridden by initial + data = {k: v for k, v in EMPTY_UNCERTAINTY.items()} + data.update(initial or {}) + # Distribution + try: + uc_type = int(data.get("uncertainty type", 0)) + except Exception: + uc_type = 0 + self.distribution.setCurrentIndex(uc_type) + # Fields (string form for QLineEdit) + def to_str(val): + return "nan" if val is None or (isinstance(val, float) and np.isnan(val)) else str(val) + + self.loc.setText(to_str(data.get("loc", np.nan))) + self.scale.setText(to_str(data.get("scale", np.nan))) + self.shape.setText(to_str(data.get("shape", np.nan))) + self.minimum.setText(to_str(data.get("minimum", np.nan))) + self.maximum.setText(to_str(data.get("maximum", np.nan))) + self._check_negative() + + @property + def _distribution_loc_label(self) -> str: + if self.dist.id == LognormalUncertainty.id: + return "Loc (ln(mean)):" + elif self.dist.id == TriangularUncertainty.id: + return "Mode:" + elif self.dist.id == BetaUncertainty.id: + return "Loc / alpha:" + elif self.dist.id in {GammaUncertainty.id, WeibullUncertainty.id}: + return "Loc / offset:" + else: + return "Mean:" + + def _hide_params(self, *params, hide: bool = True) -> None: + if "loc" in params: + self.loc_label.setHidden(hide) + self.loc.setHidden(hide) + if "scale" in params: + self.scale_label.setHidden(hide) + self.scale.setHidden(hide) + if "shape" in params: + self.shape_label.setHidden(hide) + self.shape.setHidden(hide) + if "min" in params: + self.min_label.setHidden(hide) + self.minimum.setHidden(hide) + if "max" in params: + self.max_label.setHidden(hide) + self.maximum.setHidden(hide) + + def _on_distribution_changed(self, index: int) -> None: + self.dist = uncertainty.id_dict[index] + + # Show/hide fields per distribution (mirror wizard) + if self.dist.id in {0, 1}: # Undefined / NoUncertainty + self._hide_params("loc", "scale", "shape", "min", "max") + elif self.dist.id in {2, 3}: # Normal / Lognormal + self._hide_params("shape", "min", "max") + self._hide_params("loc", "scale", hide=False) + elif self.dist.id in {4, 7}: # Uniform / DiscreteUniform + self._hide_params("loc", "scale", "shape") + self._hide_params("min", "max", hide=False) + elif self.dist.id in {5, 6}: # Triangular / Bernoulli-like (min/max/loc) + self._hide_params("scale", "shape") + self._hide_params("loc", "min", "max", hide=False) + elif self.dist.id in {8, 9, 10, 11, 12}: # Other 3-param + self._hide_params("min", "max") + self._hide_params("loc", "scale", "shape", hide=False) + + # Special handling (lognormal and calculated mean label) + if self.dist.id == LognormalUncertainty.id: + self.mean.setHidden(False) + self.mean_label.setHidden(False) + # Convert existing loc to log-space if coming from non-lognormal + if self.previous_dist_id is not None and self.previous_dist_id != LognormalUncertainty.id: + self._extract_lognormal_loc_from_mean() + self._sync_mean_from_loc() + else: + self.mean.setHidden(True) + self.mean_label.setHidden(True) + # If switching away from lognormal, set loc to linear amount if mean present + if self.previous_dist_id == LognormalUncertainty.id: + try: + mean_val = float(self.mean.text()) if self.mean.text() else np.nan + if not np.isnan(mean_val): + self.loc.setText(str(mean_val)) + except Exception: + pass + + # Calculated mean visibility + show_calc = self.dist.id in self.mean_is_calculated + self.calc_mean_label.setHidden(not show_calc) + self.calc_mean.setHidden(not show_calc) + + # Update labels + self.loc_label.setText(self._distribution_loc_label) + self.previous_dist_id = self.dist.id + self.fields_box.updateGeometry() + + # Update plot and OK state + self._generate_plot() + self._update_ok_state() + + def _extract_lognormal_loc_from_mean(self) -> None: + """Set loc to ln(mean) when switching to lognormal, if mean is known.""" + try: + mtxt = self.mean.text().strip() + if not mtxt: + return + val = float(mtxt) + if val == 0: + self.loc.setText("nan") + else: + val = -1 * val if val < 0 else val + self.loc.setText(str(np.log(val))) + except Exception: + self.loc.setText("nan") + + def _sync_mean_from_loc(self) -> None: + if not self.loc.text(): + return + try: + self.mean.setText(str(np.exp(float(self.loc.text())))) + except Exception: + self.mean.setText("nan") + self._update_ok_state() + + def _sync_loc_from_mean(self) -> None: + if not self.mean.hasAcceptableInput(): + self.loc.setText("nan") + self._update_ok_state() + return + try: + val = float(self.mean.text()) if self.mean.text() else float("nan") + except Exception: + val = float("nan") + if np.isnan(val) or val == 0: + self.loc.setText("nan") + else: + val = -1 * val if val < 0 else val + self.loc.setText(str(np.log(val))) + self._update_ok_state() + + def _check_negative(self) -> None: + # Special case for lognormal negative mean + try: + if not self.mean.hasAcceptableInput(): + return + val = float(self.mean.text()) if self.mean.text() else float("nan") + except Exception: + val = float("nan") + self.negative.setChecked(bool(not np.isnan(val) and val < 0)) + + def _standard_dist_fields(self, dist_id: int) -> list: + if dist_id in {2, 3}: + return ["loc", "scale"] + elif dist_id in {4, 7}: + return ["minimum", "maximum"] + elif dist_id in {5, 6}: + return ["loc", "minimum", "maximum"] + elif dist_id in {8, 9, 10, 11, 12}: + return ["loc", "scale", "shape"] + else: + return [] + + @property + def _uncertainty_info(self) -> dict: + data = {k: v for k, v in EMPTY_UNCERTAINTY.items()} + data["uncertainty type"] = self.distribution.currentIndex() + data["negative"] = bool(self.negative.isChecked()) + # Pull values from widgets + def as_float(txt: str) -> float: + try: + val = float(txt) + return val + except Exception: + return float("nan") + + for field in self._standard_dist_fields(data["uncertainty type"]): + widget = { + "loc": self.loc, + "scale": self.scale, + "shape": self.shape, + "minimum": self.minimum, + "maximum": self.maximum, + }[field] + data[field] = as_float(widget.text()) + return data + + def _completed_active_fields(self) -> bool: + # Mirror wizard validations + dist_id = self.dist.id + def ok_lineedit(le: QtWidgets.QLineEdit) -> bool: + return bool(le.hasAcceptableInput() and le.text()) + + if dist_id in {0, 1}: + return True + elif dist_id in {2, 3}: + return ok_lineedit(self.loc) and ok_lineedit(self.scale) + elif dist_id in {4, 7}: + return ok_lineedit(self.minimum) and ok_lineedit(self.maximum) + elif dist_id in {5, 6}: + if not (ok_lineedit(self.minimum) and ok_lineedit(self.maximum) and ok_lineedit(self.loc)): + return False + try: + return float(self.minimum.text()) < float(self.loc.text()) < float(self.maximum.text()) + except Exception: + return False + elif dist_id in {8, 9, 10, 11, 12}: + return ok_lineedit(self.scale) and ok_lineedit(self.shape) and ok_lineedit(self.loc) + return False + + def _update_ok_state(self) -> None: + ok_btn = self.buttons.button(QtWidgets.QDialogButtonBox.Ok) + ok_btn.setEnabled(self._completed_active_fields()) + + def _generate_plot(self) -> None: + # Update calculated mean if applicable and render sample + if self.dist is None: + return + complete = self._completed_active_fields() or self.dist.id in {UndefinedUncertainty.id, NoUncertainty.id} + if not complete: + self._update_ok_state() + return + array = self.dist.from_dicts(self._uncertainty_info) + # Calculated mean display for specific distributions + if self.dist.id in self.mean_is_calculated: + try: + calc = self.dist.statistics(array).get("mean") + except TypeError: + # DiscreteUniform workaround + array = self.dist.fix_nan_minimum(array) + calc = (array["maximum"] + array["minimum"]) / 2 + calc = calc.mean() if isinstance(calc, np.ndarray) else calc + self.calc_mean.setText(str(float(calc))) + # Vertical line value + if self.dist.id == LognormalUncertainty.id: + vline = self.dist.statistics(array).get("median") + elif self.dist.id in {UndefinedUncertainty.id, NoUncertainty.id}: + # Best effort: use loc as "mean" placeholder + try: + vline = float(self.loc.text()) if self.loc.text() else np.nan + except Exception: + vline = np.nan + else: + vline = self.dist.statistics(array).get("mean") + # Sample data + data = self.dist.random_variables(array, 1000) + if not np.any(np.isnan(data)): + try: + self.plot.plot(data, vline) + except RuntimeError as e: + log.error("%s: plotting failed, retry without KDE", e) + try: + sns.histplot(data.T, kde=False, stat="density", ax=self.plot.ax, edgecolor="none") + self.plot.ax.axvline(vline, label="Mean / amount", c="r", ymax=0.98) + self.plot.ax.legend(loc="upper right") + self.plot.canvas.draw() + except Exception: + pass + self._update_ok_state() + + def _on_accept(self) -> None: + try: + self.result_array = self.dist.from_dicts(self._uncertainty_info) + except Exception as e: + QtWidgets.QMessageBox.warning( + self, + "Invalid uncertainty", + str(e), + QtWidgets.QMessageBox.Ok, + QtWidgets.QMessageBox.Ok, + ) + return + self.accept() + + +class SimpleDistributionPlot(ABPlot): + def plot(self, data: np.ndarray, mean: float, label: str = "Value"): + self.reset_plot() + try: + sns.histplot(data.T, kde=True, stat="density", ax=self.ax, edgecolor="none") + except RuntimeError as e: + log.error("%s: Plotting without KDE.", e) + sns.histplot(data.T, kde=False, stat="density", ax=self.ax, edgecolor="none") + self.ax.set_xlabel(label) + self.ax.set_ylabel("Probability density") + # Add vertical line at given mean of x-axis + self.ax.axvline(mean, label="Mean / amount", c="r", ymax=0.98) + self.ax.legend(loc="upper right") + _, height = self.canvas.get_width_height() + self.setMinimumHeight(height / 2) + self.canvas.draw() + + +__all__ = ["UncertaintyDialog"] + From acf238200643a20e210bab18293c2976e1ea1fd2 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 28 Oct 2025 09:03:08 +0100 Subject: [PATCH 053/267] Second iteration of uncertainty dialog --- .../exchange/exchange_uncertainty_modify.py | 12 +++-- activity_browser/ui/dialogs/__init__.py | 1 + .../ui/dialogs/uncertainty_dialog.py | 49 +++++++++++-------- 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/activity_browser/actions/exchange/exchange_uncertainty_modify.py b/activity_browser/actions/exchange/exchange_uncertainty_modify.py index 3da988b2a..55289f19b 100644 --- a/activity_browser/actions/exchange/exchange_uncertainty_modify.py +++ b/activity_browser/actions/exchange/exchange_uncertainty_modify.py @@ -3,8 +3,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -from activity_browser.ui.dialogs import UncertaintyWizard - +from activity_browser.ui.dialogs import UncertaintyDialog class ExchangeUncertaintyModify(ABAction): """ @@ -17,4 +16,11 @@ class ExchangeUncertaintyModify(ABAction): @staticmethod @exception_dialogs def run(exchanges: List[Any]): - UncertaintyWizard(exchanges[0], application.main_window).show() + + ok, array = UncertaintyDialog.get_uncertainty( + parent=application.main_window, + initial=exchanges[0].get("uncertainty", {}) + ) + + if not ok: + return diff --git a/activity_browser/ui/dialogs/__init__.py b/activity_browser/ui/dialogs/__init__.py index c9a7650e2..d50ef14d4 100644 --- a/activity_browser/ui/dialogs/__init__.py +++ b/activity_browser/ui/dialogs/__init__.py @@ -4,4 +4,5 @@ from .uncertainty import UncertaintyWizard from .new_node_dialog import NewNodeDialog from .progress_dialog import ABProgressDialog +from .uncertainty_dialog import UncertaintyDialog diff --git a/activity_browser/ui/dialogs/uncertainty_dialog.py b/activity_browser/ui/dialogs/uncertainty_dialog.py index 9a8fb83d5..bf2cef48a 100644 --- a/activity_browser/ui/dialogs/uncertainty_dialog.py +++ b/activity_browser/ui/dialogs/uncertainty_dialog.py @@ -7,15 +7,24 @@ import seaborn as sns from qtpy import QtCore, QtGui, QtWidgets -from stats_arrays import uncertainty_choices as uncertainty -from stats_arrays.distributions import * # noqa: F401,F403 - mirror wizard usage +import stats_arrays as sa -from ...ui.widgets.plot import ABPlot -from ...bwutils.uncertainty import EMPTY_UNCERTAINTY +from activity_browser.ui.widgets import ABPlot log = getLogger(__name__) +EMPTY_UNCERTAINTY = { + "uncertainty type": sa.UndefinedUncertainty.id, + "loc": np.NaN, + "scale": np.NaN, + "shape": np.NaN, + "minimum": np.NaN, + "maximum": np.NaN, + "negative": False, +} + + class UncertaintyDialog(QtWidgets.QDialog): """Single-step dialog for defining a stats_arrays uncertainty. @@ -38,16 +47,16 @@ def __init__(self, parent=None, initial: Optional[dict] = None): self.result_array = None # Filled on accept self.previous_dist_id: Optional[int] = None self.mean_is_calculated = { - TriangularUncertainty.id, - UniformUncertainty.id, - DiscreteUniform.id, - BetaUncertainty.id, + sa.TriangularUncertainty.id, + sa.UniformUncertainty.id, + sa.DiscreteUniform.id, + sa.BetaUncertainty.id, } # Top: distribution selection box1 = QtWidgets.QGroupBox("Select the uncertainty distribution") self.distribution = QtWidgets.QComboBox(box1) - self.distribution.addItems([ud.description for ud in uncertainty.choices]) + self.distribution.addItems([ud.description for ud in sa.uncertainty_choices]) self.distribution.currentIndexChanged.connect(self._on_distribution_changed) header_layout = QtWidgets.QGridLayout() @@ -187,13 +196,13 @@ def to_str(val): @property def _distribution_loc_label(self) -> str: - if self.dist.id == LognormalUncertainty.id: + if self.dist.id == sa.LognormalUncertainty.id: return "Loc (ln(mean)):" - elif self.dist.id == TriangularUncertainty.id: + elif self.dist.id == sa.TriangularUncertainty.id: return "Mode:" - elif self.dist.id == BetaUncertainty.id: + elif self.dist.id == sa.BetaUncertainty.id: return "Loc / alpha:" - elif self.dist.id in {GammaUncertainty.id, WeibullUncertainty.id}: + elif self.dist.id in {sa.GammaUncertainty.id, sa.WeibullUncertainty.id}: return "Loc / offset:" else: return "Mean:" @@ -216,7 +225,7 @@ def _hide_params(self, *params, hide: bool = True) -> None: self.maximum.setHidden(hide) def _on_distribution_changed(self, index: int) -> None: - self.dist = uncertainty.id_dict[index] + self.dist = sa.uncertainty.id_dict[index] # Show/hide fields per distribution (mirror wizard) if self.dist.id in {0, 1}: # Undefined / NoUncertainty @@ -235,18 +244,18 @@ def _on_distribution_changed(self, index: int) -> None: self._hide_params("loc", "scale", "shape", hide=False) # Special handling (lognormal and calculated mean label) - if self.dist.id == LognormalUncertainty.id: + if self.dist.id == sa.LognormalUncertainty.id: self.mean.setHidden(False) self.mean_label.setHidden(False) # Convert existing loc to log-space if coming from non-lognormal - if self.previous_dist_id is not None and self.previous_dist_id != LognormalUncertainty.id: + if self.previous_dist_id is not None and self.previous_dist_id != sa.LognormalUncertainty.id: self._extract_lognormal_loc_from_mean() self._sync_mean_from_loc() else: self.mean.setHidden(True) self.mean_label.setHidden(True) # If switching away from lognormal, set loc to linear amount if mean present - if self.previous_dist_id == LognormalUncertainty.id: + if self.previous_dist_id == sa.LognormalUncertainty.id: try: mean_val = float(self.mean.text()) if self.mean.text() else np.nan if not np.isnan(mean_val): @@ -385,7 +394,7 @@ def _generate_plot(self) -> None: # Update calculated mean if applicable and render sample if self.dist is None: return - complete = self._completed_active_fields() or self.dist.id in {UndefinedUncertainty.id, NoUncertainty.id} + complete = self._completed_active_fields() or self.dist.id in {sa.UndefinedUncertainty.id, sa.NoUncertainty.id} if not complete: self._update_ok_state() return @@ -401,9 +410,9 @@ def _generate_plot(self) -> None: calc = calc.mean() if isinstance(calc, np.ndarray) else calc self.calc_mean.setText(str(float(calc))) # Vertical line value - if self.dist.id == LognormalUncertainty.id: + if self.dist.id == sa.LognormalUncertainty.id: vline = self.dist.statistics(array).get("median") - elif self.dist.id in {UndefinedUncertainty.id, NoUncertainty.id}: + elif self.dist.id in {sa.UndefinedUncertainty.id, sa.NoUncertainty.id}: # Best effort: use loc as "mean" placeholder try: vline = float(self.loc.text()) if self.loc.text() else np.nan From e003f2896cfa10ebbb4d370b9ef79f210d01ee0b Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sun, 26 Oct 2025 13:24:18 +0100 Subject: [PATCH 054/267] Moving stuff around --- activity_browser/__init__.py | 2 +- .../actions/activity/activity_new_process.py | 2 +- .../actions/metadatastore_open.py | 2 +- .../layouts/panes/database_explorer.py | 3 +- activity_browser/ui/{ => core}/application.py | 24 ---- .../database_selection_dialog.py | 0 .../ui/{widgets => dialogs}/dialog.py | 0 .../{widgets => dialogs}/list_edit_dialog.py | 0 .../{widgets => dialogs}/new_node_dialog.py | 122 +++++++++--------- .../{widgets => dialogs}/progress_dialog.py | 0 .../ui/{wizards => dialogs}/uncertainty.py | 4 +- activity_browser/ui/menu_bar.py | 14 -- activity_browser/ui/widgets/__init__.py | 6 +- activity_browser/ui/wizards/__init__.py | 2 +- tests/actions/test_activity_actions.py | 2 +- 15 files changed, 73 insertions(+), 110 deletions(-) rename activity_browser/ui/{ => core}/application.py (78%) rename activity_browser/ui/{widgets => dialogs}/database_selection_dialog.py (100%) rename activity_browser/ui/{widgets => dialogs}/dialog.py (100%) rename activity_browser/ui/{widgets => dialogs}/list_edit_dialog.py (100%) rename activity_browser/ui/{widgets => dialogs}/new_node_dialog.py (97%) rename activity_browser/ui/{widgets => dialogs}/progress_dialog.py (100%) rename activity_browser/ui/{wizards => dialogs}/uncertainty.py (99%) diff --git a/activity_browser/__init__.py b/activity_browser/__init__.py index 1e67a74f5..37e699f04 100644 --- a/activity_browser/__init__.py +++ b/activity_browser/__init__.py @@ -14,7 +14,7 @@ except ImportError: import qtpy -from .ui.application import application +from .ui.core.application import application from .signals import signals def run_activity_browser(): diff --git a/activity_browser/actions/activity/activity_new_process.py b/activity_browser/actions/activity/activity_new_process.py index 425ccbb56..f2b32d5b5 100644 --- a/activity_browser/actions/activity/activity_new_process.py +++ b/activity_browser/actions/activity/activity_new_process.py @@ -6,7 +6,7 @@ from activity_browser import application, bwutils from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -from activity_browser.ui.widgets.new_node_dialog import NewNodeDialog +from activity_browser.ui.dialogs.new_node_dialog import NewNodeDialog from .activity_open import ActivityOpen diff --git a/activity_browser/actions/metadatastore_open.py b/activity_browser/actions/metadatastore_open.py index ad59cc35a..474ea0988 100644 --- a/activity_browser/actions/metadatastore_open.py +++ b/activity_browser/actions/metadatastore_open.py @@ -3,7 +3,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -from activity_browser.ui.application import global_shortcut +from activity_browser.ui.core.application import global_shortcut log = getLogger(__name__) diff --git a/activity_browser/layouts/panes/database_explorer.py b/activity_browser/layouts/panes/database_explorer.py index 909512b98..17159f633 100644 --- a/activity_browser/layouts/panes/database_explorer.py +++ b/activity_browser/layouts/panes/database_explorer.py @@ -7,7 +7,8 @@ from activity_browser import signals from activity_browser.bwutils import AB_metadata -from activity_browser.ui import widgets, application +from activity_browser.ui import widgets +from activity_browser.ui.core import application log = getLogger(__name__) diff --git a/activity_browser/ui/application.py b/activity_browser/ui/core/application.py similarity index 78% rename from activity_browser/ui/application.py rename to activity_browser/ui/core/application.py index f75a365ce..8a262a6f9 100644 --- a/activity_browser/ui/application.py +++ b/activity_browser/ui/core/application.py @@ -1,5 +1,3 @@ -import sys - from pathlib import Path from logging import getLogger @@ -114,25 +112,3 @@ def decorator(func): application = ABApplication() - - - -# -# if QSysInfo.productType() == "osx": -# # https://bugreports.qt.io/browse/QTBUG-87014 -# # https://bugreports.qt.io/browse/QTBUG-85546 -# # https://github.com/mapeditor/tiled/issues/2845 -# # https://doc.qt.io/qt-5/qoperatingsystemversion.html#MacOSBigSur-var -# supported = {"10.10", "10.11", "10.12", "10.13", "10.14", "10.15", "13.6"} -# if QSysInfo.productVersion() not in supported: -# os.environ["QT_MAC_WANTS_LAYER"] = "1" -# os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" -# log.info("Info: GPU hardware acceleration disabled") -# -# # on macos buttons silently crashes the renderer without any logs -# # confirmed that buttons works on the latest version of qt using pyside6 -# if QSysInfo.productType() in ["arch", "nixos", "osx"]: -# os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "{} --no-sandbox".format( -# os.getenv("QTWEBENGINE_CHROMIUM_FLAGS") -# ) -# log.info("Info: QtWebEngine sandbox disabled") diff --git a/activity_browser/ui/widgets/database_selection_dialog.py b/activity_browser/ui/dialogs/database_selection_dialog.py similarity index 100% rename from activity_browser/ui/widgets/database_selection_dialog.py rename to activity_browser/ui/dialogs/database_selection_dialog.py diff --git a/activity_browser/ui/widgets/dialog.py b/activity_browser/ui/dialogs/dialog.py similarity index 100% rename from activity_browser/ui/widgets/dialog.py rename to activity_browser/ui/dialogs/dialog.py diff --git a/activity_browser/ui/widgets/list_edit_dialog.py b/activity_browser/ui/dialogs/list_edit_dialog.py similarity index 100% rename from activity_browser/ui/widgets/list_edit_dialog.py rename to activity_browser/ui/dialogs/list_edit_dialog.py diff --git a/activity_browser/ui/widgets/new_node_dialog.py b/activity_browser/ui/dialogs/new_node_dialog.py similarity index 97% rename from activity_browser/ui/widgets/new_node_dialog.py rename to activity_browser/ui/dialogs/new_node_dialog.py index 8e72ad144..c723d60cb 100644 --- a/activity_browser/ui/widgets/new_node_dialog.py +++ b/activity_browser/ui/dialogs/new_node_dialog.py @@ -1,61 +1,61 @@ - -from typing import Optional, Tuple -from qtpy.QtWidgets import QDialog, QGridLayout, QLabel, QLineEdit, QPushButton, QWidget - - -class NewNodeDialog(QDialog): - """ - Gathers the paremeters for creating a new process. - """ - - def __init__(self, process: bool = True, parent: Optional[QWidget] = None): - super().__init__(parent) - layout = QGridLayout() - row = 0 - if process: - self.setWindowTitle("New process") - layout.addWidget(QLabel("Process name"), row, 0) - else: - self.setWindowTitle("New product") - layout.addWidget(QLabel("Product name"), row, 0) - self._process_name_edit = QLineEdit() - self._process_name_edit.textChanged.connect(self._handle_text_changed) - layout.addWidget(self._process_name_edit, row, 1) - row += 1 - self._ref_product_name_edit = QLineEdit() - if process: - layout.addWidget(QLabel("Product name"), row, 0) - layout.addWidget(self._ref_product_name_edit, row, 1) - row += 1 - layout.addWidget(QLabel("Unit"), row, 0) - self._unit_edit = QLineEdit("kilogram") - layout.addWidget(self._unit_edit, row, 1) - row += 1 - layout.addWidget(QLabel("Location"), row, 0) - default_loc = "GLO" if process else "" - self._location_edit = QLineEdit(default_loc) - layout.addWidget(self._location_edit, row, 1) - row += 1 - self._ok_button = QPushButton("OK") - self._ok_button.clicked.connect(self.accept) - self._ok_button.setEnabled(False) - layout.addWidget(self._ok_button, row, 0) - cancel_button = QPushButton("Cancel") - cancel_button.clicked.connect(self.reject) - layout.addWidget(cancel_button, row, 1) - self.setLayout(layout) - - def _handle_text_changed(self, text: str): - self._ok_button.setEnabled(text != "") - self._ref_product_name_edit.setPlaceholderText(text) - - def get_new_process_data(self) -> Tuple[str, str, str, str]: - """Return the parameters the user entered.""" - return ( - self._process_name_edit.text(), - self._ref_product_name_edit.text(), - self._unit_edit.text(), - self._location_edit.text() - ) - - + +from typing import Optional, Tuple +from qtpy.QtWidgets import QDialog, QGridLayout, QLabel, QLineEdit, QPushButton, QWidget + + +class NewNodeDialog(QDialog): + """ + Gathers the paremeters for creating a new process. + """ + + def __init__(self, process: bool = True, parent: Optional[QWidget] = None): + super().__init__(parent) + layout = QGridLayout() + row = 0 + if process: + self.setWindowTitle("New process") + layout.addWidget(QLabel("Process name"), row, 0) + else: + self.setWindowTitle("New product") + layout.addWidget(QLabel("Product name"), row, 0) + self._process_name_edit = QLineEdit() + self._process_name_edit.textChanged.connect(self._handle_text_changed) + layout.addWidget(self._process_name_edit, row, 1) + row += 1 + self._ref_product_name_edit = QLineEdit() + if process: + layout.addWidget(QLabel("Product name"), row, 0) + layout.addWidget(self._ref_product_name_edit, row, 1) + row += 1 + layout.addWidget(QLabel("Unit"), row, 0) + self._unit_edit = QLineEdit("kilogram") + layout.addWidget(self._unit_edit, row, 1) + row += 1 + layout.addWidget(QLabel("Location"), row, 0) + default_loc = "GLO" if process else "" + self._location_edit = QLineEdit(default_loc) + layout.addWidget(self._location_edit, row, 1) + row += 1 + self._ok_button = QPushButton("OK") + self._ok_button.clicked.connect(self.accept) + self._ok_button.setEnabled(False) + layout.addWidget(self._ok_button, row, 0) + cancel_button = QPushButton("Cancel") + cancel_button.clicked.connect(self.reject) + layout.addWidget(cancel_button, row, 1) + self.setLayout(layout) + + def _handle_text_changed(self, text: str): + self._ok_button.setEnabled(text != "") + self._ref_product_name_edit.setPlaceholderText(text) + + def get_new_process_data(self) -> Tuple[str, str, str, str]: + """Return the parameters the user entered.""" + return ( + self._process_name_edit.text(), + self._ref_product_name_edit.text(), + self._unit_edit.text(), + self._location_edit.text() + ) + + diff --git a/activity_browser/ui/widgets/progress_dialog.py b/activity_browser/ui/dialogs/progress_dialog.py similarity index 100% rename from activity_browser/ui/widgets/progress_dialog.py rename to activity_browser/ui/dialogs/progress_dialog.py diff --git a/activity_browser/ui/wizards/uncertainty.py b/activity_browser/ui/dialogs/uncertainty.py similarity index 99% rename from activity_browser/ui/wizards/uncertainty.py rename to activity_browser/ui/dialogs/uncertainty.py index e78b184c5..7a680e9d0 100644 --- a/activity_browser/ui/wizards/uncertainty.py +++ b/activity_browser/ui/dialogs/uncertainty.py @@ -7,11 +7,11 @@ from stats_arrays.distributions import * from activity_browser import actions -from .. import application +from ..core import application from ...bwutils import PedigreeMatrix, get_uncertainty_interface from ...bwutils.uncertainty import EMPTY_UNCERTAINTY -from ..figures import SimpleDistributionPlot +from ..figures.figures import SimpleDistributionPlot log = getLogger(__name__) diff --git a/activity_browser/ui/menu_bar.py b/activity_browser/ui/menu_bar.py index a70eafb3d..4406f0995 100644 --- a/activity_browser/ui/menu_bar.py +++ b/activity_browser/ui/menu_bar.py @@ -150,20 +150,6 @@ def sync(self): self.addAction(action) -# class ToolsMenu(QtWidgets.QMenu): -# """ -# Tools Menu: contains actions in regard to special tooling aspects of the AB -# """ -# -# def __init__(self, parent=None) -> None: -# super().__init__(parent) -# self.setTitle("&Tools") -# -# self.manage_plugins_action = actions.PluginWizardOpen.get_QAction() -# -# self.addAction(self.manage_plugins_action) - - class HelpMenu(QtWidgets.QMenu): """ Help Menu: contains actions that show info to the user or redirect them to online resources diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index d92fe1773..8b8afef15 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -8,7 +8,7 @@ from .item import ABAbstractItem, ABBranchItem, ABDataItem from .line import ABHLine, ABVLine from .formula_edit import ABFormulaEdit -from .progress_dialog import ABProgressDialog +from ..dialogs.progress_dialog import ABProgressDialog from .combobox import ABComboBox from .button_collapser import ABRadioButtonCollapser @@ -21,6 +21,6 @@ from .main_window import MainWindow from .central import CentralTabWidget from .menu import ABMenu -from .list_edit_dialog import ABListEditDialog +from ..dialogs.list_edit_dialog import ABListEditDialog from .drop_overlay import ABDropOverlay -from .database_selection_dialog import ABDatabaseSelectionDialog +from ..dialogs.database_selection_dialog import ABDatabaseSelectionDialog diff --git a/activity_browser/ui/wizards/__init__.py b/activity_browser/ui/wizards/__init__.py index c23411065..0eb2c33ff 100644 --- a/activity_browser/ui/wizards/__init__.py +++ b/activity_browser/ui/wizards/__init__.py @@ -1 +1 @@ -from .uncertainty import UncertaintyWizard +from ..dialogs.uncertainty import UncertaintyWizard diff --git a/tests/actions/test_activity_actions.py b/tests/actions/test_activity_actions.py index ffba13834..b68dafde2 100644 --- a/tests/actions/test_activity_actions.py +++ b/tests/actions/test_activity_actions.py @@ -48,7 +48,7 @@ def test_activity_duplicate(basic_database): # # def test_activity_new(monkeypatch, basic_database): - from activity_browser.ui.widgets.new_node_dialog import NewNodeDialog + from activity_browser.ui.dialogs.new_node_dialog import NewNodeDialog monkeypatch.setattr( NewNodeDialog, "exec_", staticmethod(lambda *args, **kwargs: True) From 8baaf27d19ea3e241691e3b172f75e0292b4617f Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sun, 26 Oct 2025 13:29:52 +0100 Subject: [PATCH 055/267] Refactor import statement for SimpleDistributionPlot to improve module organization --- activity_browser/ui/dialogs/uncertainty.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activity_browser/ui/dialogs/uncertainty.py b/activity_browser/ui/dialogs/uncertainty.py index 7a680e9d0..699f32453 100644 --- a/activity_browser/ui/dialogs/uncertainty.py +++ b/activity_browser/ui/dialogs/uncertainty.py @@ -11,7 +11,7 @@ from ...bwutils import PedigreeMatrix, get_uncertainty_interface from ...bwutils.uncertainty import EMPTY_UNCERTAINTY -from ..figures.figures import SimpleDistributionPlot +from ..figures import SimpleDistributionPlot log = getLogger(__name__) From 324db642ad938b6b8a0edeca91187a7ec210ca4a Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sun, 26 Oct 2025 13:43:02 +0100 Subject: [PATCH 056/267] Refactor plotting classes to inherit from ABPlot and remove deprecated Plot class --- .../layouts/pages/lca_results/plots.py | 82 +--- activity_browser/ui/dialogs/uncertainty.py | 27 +- activity_browser/ui/figures.py | 390 ------------------ activity_browser/ui/widgets/__init__.py | 1 + activity_browser/ui/widgets/plot.py | 65 +++ 5 files changed, 96 insertions(+), 469 deletions(-) delete mode 100644 activity_browser/ui/figures.py create mode 100644 activity_browser/ui/widgets/plot.py diff --git a/activity_browser/layouts/pages/lca_results/plots.py b/activity_browser/layouts/pages/lca_results/plots.py index 13a367683..4ee5c695e 100644 --- a/activity_browser/layouts/pages/lca_results/plots.py +++ b/activity_browser/layouts/pages/lca_results/plots.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import math from logging import getLogger @@ -6,85 +5,16 @@ import numpy as np import pandas as pd import seaborn as sns -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg -from matplotlib.figure import Figure -from qtpy import QtWidgets from bw2data import methods -from activity_browser.utils import savefilepath +from activity_browser.ui.widgets import ABPlot from activity_browser.bwutils.commontasks import wrap_text log = getLogger(__name__) -# todo: sizing of the figures needs to be improved and systematized... -# todo: Bokeh is a potential alternative as it allows interactive visualizations, -# but this issue needs to be resolved first: https://github.com/bokeh/bokeh/issues/8169 - -class Plot(QtWidgets.QWidget): - ALL_FILTER = "All Files (*.*)" - PNG_FILTER = "PNG (*.png)" - SVG_FILTER = "SVG (*.svg)" - - def __init__(self, parent=None): - super().__init__(parent) - # create figure, canvas, and axis - # self.figure = Figure(tight_layout=True) - self.figure = Figure(constrained_layout=True) - self.canvas = FigureCanvasQTAgg(self.figure) - self.canvas.setMinimumHeight(0) - - self.canvas.destroyed.connect(self.check) - - self.ax = self.figure.add_subplot(111) # create an axis - self.plot_name = "Figure" - - # set the layout - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.canvas) - self.setLayout(layout) - self.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding - ) - self.updateGeometry() - - def check(self): - print("WHY DELETE") - - def plot(self, *args, **kwargs): - raise NotImplementedError - - def reset_plot(self) -> None: - self.figure.clf() - self.ax = self.figure.add_subplot(111) - - def get_canvas_size_in_inches(self): - # print("Canvas size:", self.canvas.get_width_height()) - return tuple(x / self.figure.dpi for x in self.canvas.get_width_height()) - - def to_png(self): - """Export to .png format.""" - filepath = savefilepath( - default_file_name=self.plot_name, file_filter=self.PNG_FILTER - ) - if filepath: - if not filepath.endswith(".png"): - filepath += ".png" - self.figure.savefig(filepath) - - def to_svg(self): - """Export to .svg format.""" - filepath = savefilepath( - default_file_name=self.plot_name, file_filter=self.SVG_FILTER - ) - if filepath: - if not filepath.endswith(".svg"): - filepath += ".svg" - self.figure.savefig(filepath) - - -class LCAResultsBarChart(Plot): +class LCAResultsBarChart(ABPlot): """ " Generate a bar chart comparing the absolute LCA scores of the products""" def __init__(self, parent=None): @@ -116,7 +46,7 @@ def plot(self, df: pd.DataFrame, method: tuple, labels: list): self.canvas.draw() -class LCAResultsPlot(Plot): +class LCAResultsPlot(ABPlot): def __init__(self, parent=None): super().__init__(parent) self.plot_name = "LCA heatmap" @@ -180,7 +110,7 @@ def plot(self, df: pd.DataFrame, invert_plot: bool = False): self.canvas.draw() -class ContributionPlot(Plot): +class ContributionPlot(ABPlot): MAX_LEGEND = 30 def __init__(self, parent=None): @@ -280,7 +210,7 @@ def plot(self, df: pd.DataFrame, unit: str = None): self.canvas.draw() -class CorrelationPlot(Plot): +class CorrelationPlot(ABPlot): def __init__(self, parent=None): super().__init__(parent) sns.set(style="darkgrid") @@ -344,7 +274,7 @@ def plot(self, df: pd.DataFrame): self.canvas.draw() -class MonteCarloPlot(Plot): +class MonteCarloPlot(ABPlot): """Monte Carlo plot.""" def __init__(self, parent=None): diff --git a/activity_browser/ui/dialogs/uncertainty.py b/activity_browser/ui/dialogs/uncertainty.py index 699f32453..0341bda51 100644 --- a/activity_browser/ui/dialogs/uncertainty.py +++ b/activity_browser/ui/dialogs/uncertainty.py @@ -1,17 +1,18 @@ from logging import getLogger import numpy as np +import seaborn as sns + from qtpy import QtCore, QtGui, QtWidgets from qtpy.QtCore import Signal, Slot from stats_arrays import uncertainty_choices as uncertainty from stats_arrays.distributions import * -from activity_browser import actions -from ..core import application +from activity_browser import actions, application +from activity_browser.ui.widgets.plot import ABPlot from ...bwutils import PedigreeMatrix, get_uncertainty_interface from ...bwutils.uncertainty import EMPTY_UNCERTAINTY -from ..figures import SimpleDistributionPlot log = getLogger(__name__) @@ -713,3 +714,23 @@ def generate_plot(self) -> None: if not np.any(np.isnan(data)): self.plot.plot(data, median) self.enable_pedigree.emit(True) + + +class SimpleDistributionPlot(ABPlot): + def plot(self, data: np.ndarray, mean: float, label: str = "Value"): + self.reset_plot() + try: + sns.histplot(data.T, kde=True, stat="density", ax=self.ax, edgecolor="none") + except RuntimeError as e: + log.error("{}: Plotting without KDE.".format(e)) + sns.histplot( + data.T, kde=False, stat="density", ax=self.ax, edgecolor="none" + ) + self.ax.set_xlabel(label) + self.ax.set_ylabel("Probability density") + # Add vertical line at given mean of x-axis + self.ax.axvline(mean, label="Mean / amount", c="r", ymax=0.98) + self.ax.legend(loc="upper right") + _, height = self.canvas.get_width_height() + self.setMinimumHeight(height / 2) + self.canvas.draw() diff --git a/activity_browser/ui/figures.py b/activity_browser/ui/figures.py deleted file mode 100644 index 0b618fee1..000000000 --- a/activity_browser/ui/figures.py +++ /dev/null @@ -1,390 +0,0 @@ -import math -from logging import getLogger - -import numpy as np -import pandas as pd -import seaborn as sns -import bw2data as bd - -import matplotlib.pyplot as plt -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg -from matplotlib.figure import Figure -from qtpy import QtWidgets - -from activity_browser.utils import savefilepath -from activity_browser.bwutils.commontasks import wrap_text - -log = getLogger(__name__) - - -class Plot(QtWidgets.QWidget): - ALL_FILTER = "All Files (*.*)" - PNG_FILTER = "PNG (*.png)" - SVG_FILTER = "SVG (*.svg)" - - def __init__(self, parent=None): - super().__init__(parent) - # create figure, canvas, and axis - # self.figure = Figure(tight_layout=True) - self.figure = Figure(constrained_layout=True) - self.canvas = FigureCanvasQTAgg(self.figure) - self.canvas.setMinimumHeight(0) - - self.canvas.destroyed.connect(self.check) - - self.ax = self.figure.add_subplot(111) # create an axis - self.plot_name = "Figure" - - # set the layout - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.canvas) - self.setLayout(layout) - self.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding - ) - self.updateGeometry() - - def check(self): - print("WHY DELETE") - - def plot(self, *args, **kwargs): - raise NotImplementedError - - def reset_plot(self) -> None: - self.figure.clf() - self.ax = self.figure.add_subplot(111) - - def get_canvas_size_in_inches(self): - # print("Canvas size:", self.canvas.get_width_height()) - return tuple(x / self.figure.dpi for x in self.canvas.get_width_height()) - - def to_png(self): - """Export to .png format.""" - filepath = savefilepath( - default_file_name=self.plot_name, file_filter=self.PNG_FILTER - ) - if filepath: - if not filepath.endswith(".png"): - filepath += ".png" - self.figure.savefig(filepath) - - def to_svg(self): - """Export to .svg format.""" - filepath = savefilepath( - default_file_name=self.plot_name, file_filter=self.SVG_FILTER - ) - if filepath: - if not filepath.endswith(".svg"): - filepath += ".svg" - self.figure.savefig(filepath) - - -class LCAResultsBarChart(Plot): - """ " Generate a bar chart comparing the absolute LCA scores of the products""" - - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "LCA scores" - - def plot(self, df: pd.DataFrame, method: tuple, labels: list): - self.reset_plot() - height_inches, width_inches = self.get_canvas_size_in_inches() - self.figure.set_size_inches(height_inches, width_inches) - - # https://github.com/LCA-ActivityBrowser/activity-browser/issues/489 - df.index = pd.Index(labels) # Replace index of tuples - show_legend = df.shape[1] != 1 # Do not show the legend for 1 column - df.plot.barh(ax=self.ax, legend=show_legend) - self.ax.invert_yaxis() - - # labels - self.ax.set_yticks(np.arange(len(labels))) - self.ax.set_xlabel(bd.methods[method].get("unit")) - self.ax.set_title(", ".join([m for m in method])) - # self.ax.set_yticklabels(labels, minor=False) - - # grid - self.ax.grid(which="major", axis="x", color="grey", linestyle="dashed") - self.ax.set_axisbelow(True) # puts gridlines behind bars - - # draw - self.canvas.draw() - - -class LCAResultsPlot(Plot): - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "LCA heatmap" - - def plot(self, df: pd.DataFrame, invert_plot: bool = False): - """Plot a heatmap grid of the different impact categories and reference flows.""" - # need to clear the figure and add axis again - # because of the colorbar which does not get removed by the ax.clear() - self.reset_plot() - - dfp = df.copy() - dfp.index = dfp["index"] - dfp.drop( - dfp.select_dtypes(["object"]), axis=1, inplace=True - ) # get rid of all non-numeric columns (metadata) - if "amount" in dfp.columns: - dfp.drop(["amount"], axis=1, inplace=True) # Drop the 'amount' col - if "Score" in dfp.index: - dfp.drop("Score", inplace=True) - - # avoid figures getting too large horizontally - dfp.index = [wrap_text(i, max_length=40) for i in dfp.index] - dfp.columns = [wrap_text(i, max_length=20) for i in dfp.columns] - prop = dfp.divide(dfp.abs().max(axis=0)).multiply(100) - dfp.replace(np.nan, 0, inplace=True) - if invert_plot: - dfp = dfp.T - prop = prop.T - - # set different color palette depending on whether all values are positive or not - if ( - dfp.min(axis=None) < 0 and dfp.max(axis=None) > 0 - ): # has both negative AND positive values - cmap = sns.color_palette("vlag_r", as_cmap=True) - else: # has only positive OR negative values - cmap = sns.color_palette("Blues", as_cmap=True) - - sns.heatmap( - prop, - ax=self.ax, - cmap=cmap, - annot=dfp, - linewidths=0.05, - annot_kws={ - "size": 11 if dfp.shape[1] <= 8 else 9, - "rotation": 0 if dfp.shape[1] <= 8 else 60, - }, - cbar_kws={"format": "%.0f%%"}, - ) - self.ax.tick_params(labelsize=8) - if dfp.shape[1] > 5: - self.ax.set_xticklabels(self.ax.get_xticklabels(), rotation="vertical") - self.ax.set_yticklabels(self.ax.get_yticklabels(), rotation="horizontal") - - # refresh canvas - size_inches = (2 + dfp.shape[0] * 0.5, 4 + dfp.shape[0] * 0.55) - self.figure.set_size_inches(self.get_canvas_size_in_inches()[0], size_inches[1]) - size_pixels = self.figure.get_size_inches() * self.figure.dpi - self.setMinimumHeight(size_pixels[1]) - - self.canvas.draw() - - -class ContributionPlot(Plot): - MAX_LEGEND = 30 - - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "Contributions" - self.parent = parent - - def plot(self, df: pd.DataFrame, unit: str = None): - """Plot a horizontal stacked bar chart of contributions, - add 'total' marker if both positive and negative results are present.""" - dfp = df.copy() - dfp = dfp.iloc[:, ::-1] # reverse column names so they align with calculation setup and rest of results - - dfp.index = dfp["index"] - dfp.drop( - dfp.select_dtypes(["object"]), axis=1, inplace=True - ) # get rid of all non-numeric columns (metadata) - if "Score" in dfp.index: - dfp.drop("Score", inplace=True) - # drop rows if all values are 0 - dfp = dfp.loc[~(dfp == 0).all(axis=1)] - - self.ax.clear() - canvas_width_inches, canvas_height_inches = self.get_canvas_size_in_inches() - optimal_height_inches = 4 + dfp.shape[1] * 0.55 - # print('Optimal Contribution plot height:', optimal_height_inches) - self.figure.set_size_inches(canvas_width_inches, optimal_height_inches) - - # avoid figures getting too large horizontally - dfp.index = pd.Index([wrap_text(str(i), max_length=40) for i in dfp.index]) - dfp.columns = pd.Index([wrap_text(i, max_length=40) for i in dfp.columns]) - # Strip invalid characters from the ends of row/column headers - dfp.index = dfp.index.str.strip("_ \n\t") - dfp.columns = dfp.columns.str.strip("_ \n\t") - - # set colormap to use - items = dfp.shape[0] # how many contribution items - # skip grey and black at start/end of cmap - cmap = plt.cm.nipy_spectral_r(np.linspace(0, 1, items + 2))[1:-1] - colors = {item: color for item, color in zip(dfp.index, cmap)} - # overwrite rest values to grey - colors["Rest (+)"] = [0.8, 0.8, 0.8, 1.] - colors["Rest (-)"] = [0.8, 0.8, 0.8, 1.] - - dfp.T.plot.barh( - stacked=True, - color=colors, - ax=self.ax, - legend=False if dfp.shape[0] >= self.MAX_LEGEND else True, - ) - self.ax.tick_params(labelsize=8) - if unit: - self.ax.set_xlabel(unit) - - # show legend if not too many items - if not dfp.shape[0] >= self.MAX_LEGEND: - plt.rc("legend", **{"fontsize": 8}) - ncols = math.ceil(dfp.shape[0] * 0.6 / optimal_height_inches) - # print('Ncols:', ncols, dfp.shape[0] * 0.55, optimal_height_inches) - self.ax.legend(loc="center left", bbox_to_anchor=(1, 0.5), ncol=ncols) - - # grid - self.ax.grid(which="major", axis="x", color="grey", linestyle="dashed") - self.ax.set_axisbelow(True) # puts gridlines behind bars - # make the zero line more present - grid = self.ax.get_xgridlines() - # get the 0 line from all gridlines - label_pos = [i for i, label in enumerate(self.ax.get_xticklabels()) if label.get_position()[0] == 0.0] - if len(label_pos) > 0: - zero_line = grid[label_pos[0]] - zero_line.set_color("black") - zero_line.set_linestyle("solid") - - # total marker when enabled and both negative and positive results are present in a column - if self.parent.score_marker: - marker_size = max(min(150 / dfp.shape[1], 35), 10) # set marker size dynamic between 10 - 35 - for i, col in enumerate(dfp): - total = np.sum(dfp[col]) - abs_total = np.sum(np.abs(dfp[col])) - if abs(total) != abs_total: - self.ax.plot(total, i, - markersize=marker_size, marker="d", fillstyle="left", - markerfacecolor="black", markerfacecoloralt="grey", markeredgecolor="white") - - # TODO review: remove or enable - - # refresh canvas - # size_inches = (2 + dfp.shape[0] * 0.5, 4 + dfp.shape[1] * 0.55) - # self.figure.set_size_inches(self.get_canvas_size_in_inches()[0], size_inches[1]) - - size_pixels = self.figure.get_size_inches() * self.figure.dpi - self.setMinimumHeight(size_pixels[1]) - self.canvas.draw() - - -class CorrelationPlot(Plot): - def __init__(self, parent=None): - super().__init__(parent) - sns.set(style="darkgrid") - - def plot(self, df: pd.DataFrame): - """Plot a heatmap of correlations between different reference flows.""" - # need to clear the figure and add axis again - # because of the colorbar which does not get removed by the ax.clear() - self.reset_plot() - canvas_size = self.canvas.get_width_height() - # print("Canvas size:", canvas_size) - size = (4 + df.shape[1] * 0.3, 4 + df.shape[1] * 0.3) - self.figure.set_size_inches(size[0], size[1]) - - corr = df.corr() - # Generate a mask for the upper triangle - mask = np.zeros_like(corr, dtype=bool) - mask[np.triu_indices_from(mask)] = True - # Draw the heatmap with the mask and correct aspect ratio - vmax = np.abs(corr.values[~mask]).max() - # vmax = np.abs(corr).max() - sns.heatmap( - corr, - mask=mask, - cmap=plt.cm.PuOr, - vmin=-vmax, - vmax=vmax, - square=True, - linecolor="lightgray", - linewidths=1, - ax=self.ax, - ) - - df_lte8_cols = df.shape[1] <= 8 - for i in range(len(corr)): - self.ax.text( - i + 0.5, - i + 0.5, - corr.columns[i], - ha="center", - va="center", - rotation=0 if df_lte8_cols else 45, - size=11 if df_lte8_cols else 9, - ) - for j in range(i + 1, len(corr)): - s = "{:.3f}".format(corr.values[i, j]) - self.ax.text( - j + 0.5, - i + 0.5, - s, - ha="center", - va="center", - rotation=0 if df_lte8_cols else 45, - size=11 if df_lte8_cols else 9, - ) - self.ax.axis("off") - - # refresh canvas - size_pixels = self.figure.get_size_inches() * self.figure.dpi - self.setMinimumHeight(size_pixels[1]) - self.canvas.draw() - - -class MonteCarloPlot(Plot): - """Monte Carlo plot.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "Monte Carlo" - - def plot(self, df: pd.DataFrame, method: tuple): - self.ax.clear() - - for col in df.columns: - color = self.ax._get_lines.get_next_color() - df[col].hist( - ax=self.ax, - figure=self.figure, - label=col, - density=True, - color=color, - alpha=0.5, - ) # , histtype="step") - # self.ax.axvline(df[col].median(), color=color) - self.ax.axvline(df[col].mean(), color=color) - - self.ax.set_xlabel(bd.methods[method]["unit"]) - self.ax.set_ylabel("Probability") - self.ax.legend( - loc="upper center", - bbox_to_anchor=(0.5, -0.07), - ) # ncol=2 - - # lconfi, upconfi =mc['statistics']['interval'][0], mc['statistics']['interval'][1] - - self.canvas.draw() - - -class SimpleDistributionPlot(Plot): - def plot(self, data: np.ndarray, mean: float, label: str = "Value"): - self.reset_plot() - try: - sns.histplot(data.T, kde=True, stat="density", ax=self.ax, edgecolor="none") - except RuntimeError as e: - log.error("{}: Plotting without KDE.".format(e)) - sns.histplot( - data.T, kde=False, stat="density", ax=self.ax, edgecolor="none" - ) - self.ax.set_xlabel(label) - self.ax.set_ylabel("Probability density") - # Add vertical line at given mean of x-axis - self.ax.axvline(mean, label="Mean / amount", c="r", ymax=0.98) - self.ax.legend(loc="upper right") - _, height = self.canvas.get_width_height() - self.setMinimumHeight(height / 2) - self.canvas.draw() \ No newline at end of file diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index 8b8afef15..b60325b40 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -24,3 +24,4 @@ from ..dialogs.list_edit_dialog import ABListEditDialog from .drop_overlay import ABDropOverlay from ..dialogs.database_selection_dialog import ABDatabaseSelectionDialog +from .plot import ABPlot diff --git a/activity_browser/ui/widgets/plot.py b/activity_browser/ui/widgets/plot.py new file mode 100644 index 000000000..1e665dc08 --- /dev/null +++ b/activity_browser/ui/widgets/plot.py @@ -0,0 +1,65 @@ +from logging import getLogger + +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg +from matplotlib.figure import Figure +from qtpy import QtWidgets + +from activity_browser.utils import savefilepath + +log = getLogger(__name__) + + +class ABPlot(QtWidgets.QWidget): + ALL_FILTER = "All Files (*.*)" + PNG_FILTER = "PNG (*.png)" + SVG_FILTER = "SVG (*.svg)" + + def __init__(self, parent=None): + super().__init__(parent) + # create figure, canvas, and axis + self.figure = Figure(constrained_layout=True) + self.canvas = FigureCanvasQTAgg(self.figure) + self.canvas.setMinimumHeight(0) + + self.ax = self.figure.add_subplot(111) # create an axis + self.plot_name = "Figure" + + # set the layout + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.canvas) + self.setLayout(layout) + self.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + self.updateGeometry() + + def plot(self, *args, **kwargs): + raise NotImplementedError + + def reset_plot(self) -> None: + self.figure.clf() + self.ax = self.figure.add_subplot(111) + + def get_canvas_size_in_inches(self): + return tuple(x / self.figure.dpi for x in self.canvas.get_width_height()) + + def to_png(self): + """Export to .png format.""" + filepath = savefilepath( + default_file_name=self.plot_name, file_filter=self.PNG_FILTER + ) + if filepath: + if not filepath.endswith(".png"): + filepath += ".png" + self.figure.savefig(filepath) + + def to_svg(self): + """Export to .svg format.""" + filepath = savefilepath( + default_file_name=self.plot_name, file_filter=self.SVG_FILTER + ) + if filepath: + if not filepath.endswith(".svg"): + filepath += ".svg" + self.figure.savefig(filepath) + From d5418aa40cd667a4d0ed07d5d3acf2f8b04e7f1d Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sun, 26 Oct 2025 13:53:06 +0100 Subject: [PATCH 057/267] Refactor import statements to replace references to UncertaintyWizard from wizards to dialogs --- .../exchange/exchange_uncertainty_modify.py | 2 +- .../actions/method/cf_uncertainty_modify.py | 2 +- .../pages/parameters/parameter_models.py | 2 +- activity_browser/ui/dialogs/__init__.py | 7 + activity_browser/ui/dialogs/dialog.py | 572 ------------------ activity_browser/ui/widgets/__init__.py | 3 - tests/actions/test_exchange_actions.py | 2 +- tests/actions/test_method_actions.py | 2 +- 8 files changed, 12 insertions(+), 580 deletions(-) create mode 100644 activity_browser/ui/dialogs/__init__.py delete mode 100644 activity_browser/ui/dialogs/dialog.py diff --git a/activity_browser/actions/exchange/exchange_uncertainty_modify.py b/activity_browser/actions/exchange/exchange_uncertainty_modify.py index f938fb083..3da988b2a 100644 --- a/activity_browser/actions/exchange/exchange_uncertainty_modify.py +++ b/activity_browser/actions/exchange/exchange_uncertainty_modify.py @@ -3,7 +3,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -from activity_browser.ui.wizards import UncertaintyWizard +from activity_browser.ui.dialogs import UncertaintyWizard class ExchangeUncertaintyModify(ABAction): diff --git a/activity_browser/actions/method/cf_uncertainty_modify.py b/activity_browser/actions/method/cf_uncertainty_modify.py index 16d6bc735..564c96eb7 100644 --- a/activity_browser/actions/method/cf_uncertainty_modify.py +++ b/activity_browser/actions/method/cf_uncertainty_modify.py @@ -5,7 +5,7 @@ from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons -from activity_browser.ui.wizards import UncertaintyWizard +from activity_browser.ui.dialogs import UncertaintyWizard class CFUncertaintyModify(ABAction): diff --git a/activity_browser/layouts/pages/parameters/parameter_models.py b/activity_browser/layouts/pages/parameters/parameter_models.py index 1ecb44e31..9277defe5 100644 --- a/activity_browser/layouts/pages/parameters/parameter_models.py +++ b/activity_browser/layouts/pages/parameters/parameter_models.py @@ -14,7 +14,7 @@ from activity_browser import actions, signals, application, bwutils from activity_browser.mod import bw2data as bd -from activity_browser.ui.wizards import UncertaintyWizard +from activity_browser.ui.dialogs import UncertaintyWizard from .base import BaseTreeModel, EditablePandasModel, TreeItem, PandasModel diff --git a/activity_browser/ui/dialogs/__init__.py b/activity_browser/ui/dialogs/__init__.py new file mode 100644 index 000000000..c9a7650e2 --- /dev/null +++ b/activity_browser/ui/dialogs/__init__.py @@ -0,0 +1,7 @@ +from .database_selection_dialog import ABDatabaseSelectionDialog +from .list_edit_dialog import ABListEditDialog +from .progress_dialog import ABProgressDialog +from .uncertainty import UncertaintyWizard +from .new_node_dialog import NewNodeDialog +from .progress_dialog import ABProgressDialog + diff --git a/activity_browser/ui/dialogs/dialog.py b/activity_browser/ui/dialogs/dialog.py deleted file mode 100644 index b98d714fe..000000000 --- a/activity_browser/ui/dialogs/dialog.py +++ /dev/null @@ -1,572 +0,0 @@ -from logging import getLogger - -from qtpy import QtGui, QtWidgets -from qtpy.QtCore import Qt -from activity_browser.ui import widgets, icons - -log = getLogger(__name__) - - -class FilterManagerDialog(QtWidgets.QDialog): - """Set filters for a table. - - Dialog has 1 tab per given column. Each tab has rows for filters, - where type/query/other is defined. User can add/remove filters as desired. - When multiple filters exist for 1 column, user can choose AND/OR combination of filters. - AND/OR for combining columns can also be chosen. - - Required inputs: - - column names: dict --> the column names and their indices in the table - format: {'col_name': i} - Optional inputs: - - filters: dict --> pre-apply filters in the dialog (see format example below) - - selected_column: int --> open the dialog with this column tab open - - column_types: dict --> show other filters for this column - format: {'col_name': 'num'} - options: str/num, defaults to str if no type is given - - Interaction: - - call 'start_filter_dialog' of 'ABFilterableDataFrameView' to launch dialog, - filters are only applied when OK is selected. This calls self.get_filters, - which returns filter data as dict. - - example of filters (see also ABMultiColumnSortProxyModel): - filters = { - 0: {'filters': [('contains', 'heat', False), ('contains', 'electricity', False)], - 'mode': 'OR'}, - 1: {'filters': [('contains', 'market', False)]} - } - """ - - def __init__( - self, - column_names: dict, - filter_types: dict, - filters: dict = None, - selected_column: int = 0, - column_types: dict = {}, - parent=None, - ): - super().__init__(parent) - self.setWindowIcon(icons.qicons.filter) - self.setWindowTitle("Manage table filters") - - # set given filters, if any - if isinstance(filters, dict): - self.filters = filters - else: - self.filters = {} - - # create a tab for every column in the table - self.tab_widget = QtWidgets.QTabWidget() - self.tabs = [] - - # we need this dict as we may have hidden columns (e.g. CFTable) - self.col_id_2_tab_id = {} - for tab_id, col_data in enumerate(column_names.items()): - col_name, col_id = col_data - self.col_id_2_tab_id[col_id] = tab_id - tab = ColumnFilterTab( - parent=self, - state=self.filters.get(col_id, None), - col_type=column_types.get(col_name, "str"), - filter_types=filter_types, - ) - self.tabs.append(tab) - self.tab_widget.addTab(tab, col_name) - - # add AND/OR choice button. - self.and_or_buttons = AndOrRadioButtons(label_text="Combine columns:") - # in the extremely unlikely event there is only 1 column, hide the AND/OR option. - if len(column_names) == 1: - self.and_or_buttons.hide() - - # create OK/cancel buttons - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - # assemble layout - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.tab_widget) - layout.addWidget(self.and_or_buttons) - layout.addWidget(self.buttons) - self.setLayout(layout) - - # set the column that launched the dialog as the open tab - self.tab_widget.setCurrentIndex(self.col_id_2_tab_id[selected_column]) - self.tabs[selected_column].filter_rows[-1].filter_query_line.setFocus() - - @property - def get_filters(self) -> dict: - state = {} - t2c = {v: k for k, v in self.col_id_2_tab_id.items()} - for tab_id, tab in enumerate(self.tabs): - tab_state = tab.get_state - if isinstance(tab_state, dict): - state[t2c[tab_id]] = tab_state - if len(state) == 0: - return - state["mode"] = self.and_or_buttons.get_state - return state - - -class SimpleFilterDialog(QtWidgets.QDialog): - """Add one filter to a column. - - Related to FilterManagerDialog. - """ - - def __init__( - self, - column_name: dict, - filter_types: dict, - column_type: str = "str", - preset_type: str = None, - parent=None, - ): - super().__init__(parent) - self.setWindowIcon(icons.qicons.filter) - self.setWindowTitle("Add filter") - - # Create filter label and buttons - label = QtWidgets.QLabel("Define a filter for column '{}'".format(column_name)) - - if column_type == "num": - self.filter_row = NumFilterRow( - idx=0, - filter_types=filter_types, - remove_option=False, - preset_type=preset_type, - parent=self, - ) - else: - # if none of the above types, assume str - self.filter_row = StrFilterRow( - idx=0, - filter_types=filter_types, - remove_option=False, - preset_type=preset_type, - parent=self, - ) - - self.filter_row.filter_query_line.setFocus() - - # create OK/cancel buttons - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(label) - layout.addWidget(self.filter_row) - layout.addWidget(self.buttons) - self.setLayout(layout) - - @property - def get_filter(self) -> tuple: - if self.filter_row.get_state: - return self.filter_row.get_state - - -class ColumnFilterTab(QtWidgets.QWidget): - """Content of column tab. - - Required inputs: - - None - Optional inputs: - - col_type: str --> the type of column, either 'str' or 'num'. defines the search type options. - defaults to 'str' - - state: dict --> dict of existing filter state that should be re-created in UI. - - Interaction: - - def get_state: Provides the state of all relevant filter elements (filter rows, AND/OR menu) - returns: dict - - def set_state: Writes given state dict to UI elements (filter rows, AND/OR menu) - """ - - def __init__( - self, filter_types: dict, col_type: str = "str", state: dict = {}, parent=None - ): - super().__init__(parent) - self.filter_types = filter_types - self.col_type = col_type - - self.add = QtWidgets.QToolButton() - self.add.setIcon(icons.qicons.add) - self.add.setToolTip("Add a new filter for this column") - self.add.clicked.connect(self.add_row) - - self.and_or_buttons = AndOrRadioButtons( - label_text="Combine filters within column:" - ) - if self.col_type == "str": - self.and_or_buttons.set_state("OR") - - self.filter_rows = [] - self.filter_widget_layout = QtWidgets.QVBoxLayout() - self.filter_widget = QtWidgets.QWidget() - self.filter_widget.setLayout(self.filter_widget_layout) - - # set the state, adds 1 empty row if state=={} - self.set_state(state) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.filter_widget) - layout.addWidget(self.add) - layout.addStretch() - layout.addWidget(self.and_or_buttons) - self.setLayout(layout) - - def add_row(self, state: tuple = None) -> None: - """Add a new row to the self.filter_rows.""" - idx = len(self.filter_rows) - - if self.col_type == "num": - new_filter_row = NumFilterRow( - idx=idx, state=state, filter_types=self.filter_types, parent=self - ) - else: - # if none of the above types, assume str - new_filter_row = StrFilterRow( - idx=idx, state=state, filter_types=self.filter_types, parent=self - ) - - self.filter_rows.append(new_filter_row) - self.filter_widget_layout.addWidget(new_filter_row) - self.show_hide_and_or() - - def remove_row(self, idx: int) -> None: - """Remove the row from the setup""" - # remove the row from widget and self.filter_rows - self.filter_widget_layout.itemAt(idx).widget().deleteLater() - self.filter_rows.pop(idx) - # re-index the list of rows - for i, filter_row in enumerate(self.filter_rows): - filter_row.idx = i - # if there would be no remaining rows, add a new empty one - if len(self.filter_rows) == 0: - self.add_row() - self.show_hide_and_or() - - @property - def get_state(self) -> dict: - # check if there are filters - if len(self.filter_rows) == 0: - return None - # check if there are valid filters - valid_filters = [row.get_state for row in self.filter_rows if row.get_state] - if len(valid_filters) == 0: - return None - elif len(valid_filters) == 1: - return {"filters": valid_filters} - else: - return {"filters": valid_filters, "mode": self.and_or_buttons.get_state} - - def set_state(self, state: dict) -> None: - if not state: - self.add_row() - self.and_or_buttons.hide() - return - - # add one row per filter - filters = state["filters"] - self.filter_rows = [] - for filter_state in filters: - self.add_row(filter_state) - - # set state and show/hide the AND/OR widget - self.show_hide_and_or() - if state.get("mode", False): - self.and_or_buttons.set_state(state["mode"]) - - def show_hide_and_or(self) -> None: - if len(self.filter_rows) > 1: - self.and_or_buttons.show() - else: - self.and_or_buttons.hide() - - -class FilterRow(QtWidgets.QWidget): - """Convenience class for managing a filter input row. - - This class is purely intended for FilterManagerDialog and related, take this into account if using elsewhere. - - Required inputs: - - idx: int --> integer index in self.filter_rows of parent. Used as ID in parent - idx is the index position of this FilterRow in the list of rows in parent. - - filter_types: dict --> the types of filter available - Optional inputs: - - state: tuple --> tuple of existing filter state that should be re-created in UI. - - Interaction: - - def get_state: Provides the state of all relevant filter fields (filter type, query, case sensitive) - returns: tuple - - def set_state: Writes given state tuple to UI elements (filter type, query, case sensitive) - """ - - def __init__( - self, - idx: int, - filter_types: dict, - remove_option: bool = True, - preset_type: str = None, - parent=None, - ): - super().__init__(parent) - - self.idx = idx - self.filter_types = filter_types - self.filter_type = self.filter_types[self.column_type] - self.parent = parent - - self.row_layout = QtWidgets.QHBoxLayout() - - # create a 'filter type' combobox - self.filter_type_box = QtWidgets.QComboBox() - self.filter_type_box.addItems(self.filter_type) - # set a preset type if given - if isinstance(preset_type, str): - self.filter_type_box.setCurrentIndex(self.filter_type.index(preset_type)) - # add tooltip for every type option - for i, tt in enumerate(self.filter_types[self.column_type + "_tt"]): - self.filter_type_box.setItemData(i, tt, Qt.ToolTipRole) - - # create the filter input line - self.filter_query_line = QtWidgets.QLineEdit() - self.filter_query_line.setFocusPolicy(Qt.StrongFocus) - - if remove_option: - # add buttons to remove the row - self.remove = QtWidgets.QToolButton() - self.remove.setIcon(icons.qicons.delete) - self.remove.setToolTip("Remove this filter") - self.remove.clicked.connect(self.self_destruct) - - @property - def get_state(self) -> tuple: - raise NotImplementedError - - def set_state(self, state: tuple) -> None: - raise NotImplementedError - - def set_input_changes(self) -> None: - raise NotImplementedError - - def self_destruct(self) -> None: - """Remove this FilterRow object from parent.""" - self.parent.remove_row(self.idx) - - -class StrFilterRow(FilterRow): - """Convenience class for managing a filter input row for 'str' type.""" - - def __init__( - self, - idx: int, - filter_types: dict, - state: tuple = None, - remove_option: bool = True, - preset_type: str = None, - parent=None, - ): - - self.column_type = "str" - super().__init__(idx, filter_types, remove_option, preset_type, parent) - - # create case-sensitive box - self.case_sensitive_text = QtWidgets.QLabel("Case Sensitive:") - self.filter_case_sensitive_check = QtWidgets.QCheckBox() - - # assemble the layout - self.row_layout.addWidget(self.filter_type_box) - self.row_layout.addWidget(self.filter_query_line) - self.row_layout.addWidget(self.case_sensitive_text) - self.row_layout.addWidget(self.filter_case_sensitive_check) - if remove_option: - # add button to remove the row - self.row_layout.addWidget(widgets.ABVLine(self)) - self.row_layout.addWidget(self.remove) - - self.setLayout(self.row_layout) - - # set the state if one was given - if isinstance(state, tuple): - self.set_state(state) - - self.filter_type_box.currentIndexChanged.connect(self.set_input_changes) - self.set_input_changes() - - @property - def get_state(self) -> tuple: - # remove weird whitespace from input - query_line = ( - self.filter_query_line.text() - .translate(str.maketrans("", "", "\n\t\r")) - .strip() - ) - # if valid, return a tuple with the state, otherwise, return None - if query_line == "": - return None - - selected_type = self.filter_type_box.currentText() - selected_query = self.filter_query_line.text() - case_sensitive = self.filter_case_sensitive_check.isChecked() - return selected_type, selected_query, case_sensitive - - def set_state(self, state: tuple) -> None: - selected_type, selected_query, case_sensitive = state - self.filter_type_box.setCurrentIndex(self.filter_type.index(selected_type)) - self.filter_query_line.setText(selected_query) - self.filter_case_sensitive_check.setChecked(case_sensitive) - - def set_input_changes(self) -> None: - # set tooltip to currently selected item - tt = self.filter_types[self.column_type + "_tt"][ - self.filter_type_box.currentIndex() - ] - self.filter_type_box.setToolTip(tt) - - -class NumFilterRow(FilterRow): - """Convenience class for managing a filter input row for 'num' type.""" - - def __init__( - self, - idx: int, - filter_types: dict, - state: tuple = None, - remove_option: bool = True, - preset_type: str = None, - parent=None, - ): - - self.column_type = "num" - super().__init__(idx, filter_types, remove_option, preset_type, parent) - - # add an input line in case 'between' ('<= x <=') is selected - self.filter_query_line0 = QtWidgets.QLineEdit() - self.filter_query_line0.hide() - - # set 'double' validator for input lines - self.filter_query_line0.setValidator(QtGui.QDoubleValidator()) - self.filter_query_line.setValidator(QtGui.QDoubleValidator()) - - # assemble the layout - self.row_layout.addWidget(self.filter_query_line0) - self.row_layout.addWidget(self.filter_type_box) - self.row_layout.addWidget(self.filter_query_line) - if remove_option: - # add button to remove the row - self.row_layout.addWidget(widgets.ABVLine(self)) - self.row_layout.addWidget(self.remove) - - self.setLayout(self.row_layout) - - # set the state if one was given - if isinstance(state, tuple): - self.set_state(state) - - self.filter_type_box.currentIndexChanged.connect(self.set_input_changes) - self.set_input_changes() - - @property - def get_state(self) -> tuple: - # remove weird whitespace from input - query_line = ( - self.filter_query_line.text() - .translate(str.maketrans("", "", " \n\t\r")) - .strip() - ) - # if valid, return a tuple with the state, otherwise, return None - if query_line == "": - return None - - selected_type = self.filter_type_box.currentText() - selected_query = self.filter_query_line.text() - if self.filter_type_box.currentText() == "<= x <=": - selected_query = ( - self.filter_query_line0.text(), - self.filter_query_line.text(), - ) - return selected_type, selected_query - - def set_state(self, state: tuple) -> None: - selected_type, selected_query = state - self.set_input_changes() - self.filter_type_box.setCurrentIndex(self.filter_type.index(selected_type)) - if selected_type == "<= x <=": - self.filter_query_line0.setText(selected_query[0]) - self.filter_query_line.setText(selected_query[1]) - else: - self.filter_query_line.setText(selected_query) - - def set_input_changes(self) -> None: - # enable whether the extra input line is visible - if self.filter_type_box.currentText() == "<= x <=": - self.filter_query_line0.show() - else: - self.filter_query_line0.hide() - # set tooltip to currently selected item - tt = self.filter_types[self.column_type + "_tt"][ - self.filter_type_box.currentIndex() - ] - self.filter_type_box.setToolTip(tt) - - -class AndOrRadioButtons(QtWidgets.QWidget): - """Convenience class for managing AND/OR buttons. - - This class is purely intended for FilterManagerDialog and related, take this into account if using elsewhere. - - Required inputs: - - None - Optional inputs: - - label_text: str --> - - state: str --> str of existing AND/OR state that should be re-created in UI. - - Interaction: - - def get_state: Provides the state of AND/OR radio buttons (string of 'AND' or 'OR') - returns: str - - def set_state: Writes given AND/OR state UI element (string of 'AND' or 'OR') - """ - - def __init__(self, label_text: str = "", state: str = None, parent=None): - super().__init__(parent) - # create an AND/OR widget - layout = QtWidgets.QHBoxLayout() - self.btn_group = QtWidgets.QButtonGroup() - self.AND = QtWidgets.QRadioButton("AND") - self.OR = QtWidgets.QRadioButton("OR") - self.btn_group.addButton(self.AND) - self.btn_group.addButton(self.OR) - layout.addStretch() - layout.addWidget(QtWidgets.QLabel(label_text)) - layout.addWidget(self.AND) - layout.addWidget(self.OR) - self.setLayout(layout) - self.setToolTip( - "Choose how filters combine with each other.\n" - "AND must satisfy all filters, OR must satisfy at least one filter." - ) - - # set the state if one was given, otherwise, assume AND - if isinstance(state, str): - self.set_state(state) - else: - self.set_state("AND") - - @property - def get_state(self) -> str: - return self.btn_group.checkedButton().text() - - def set_state(self, state: str) -> None: - x = True - if state == "OR": - x = False - self.AND.setChecked(x) - self.OR.setChecked(not x) diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index b60325b40..4ccdf99bd 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -8,7 +8,6 @@ from .item import ABAbstractItem, ABBranchItem, ABDataItem from .line import ABHLine, ABVLine from .formula_edit import ABFormulaEdit -from ..dialogs.progress_dialog import ABProgressDialog from .combobox import ABComboBox from .button_collapser import ABRadioButtonCollapser @@ -21,7 +20,5 @@ from .main_window import MainWindow from .central import CentralTabWidget from .menu import ABMenu -from ..dialogs.list_edit_dialog import ABListEditDialog from .drop_overlay import ABDropOverlay -from ..dialogs.database_selection_dialog import ABDatabaseSelectionDialog from .plot import ABPlot diff --git a/tests/actions/test_exchange_actions.py b/tests/actions/test_exchange_actions.py index 632fff067..8115099ee 100644 --- a/tests/actions/test_exchange_actions.py +++ b/tests/actions/test_exchange_actions.py @@ -2,7 +2,7 @@ from stats_arrays.distributions import NoUncertainty, UndefinedUncertainty from activity_browser import actions, application -from activity_browser.ui.wizards import UncertaintyWizard +from activity_browser.ui.dialogs import UncertaintyWizard # def test_exchange_copy_sdf(basic_database): diff --git a/tests/actions/test_method_actions.py b/tests/actions/test_method_actions.py index 284d94b93..bd3641854 100644 --- a/tests/actions/test_method_actions.py +++ b/tests/actions/test_method_actions.py @@ -9,7 +9,7 @@ ) from activity_browser import actions -from activity_browser.ui.wizards import UncertaintyWizard +from activity_browser.ui.dialogs import UncertaintyWizard def test_cf_amount_modify(basic_database): From 61f7d2b01f0d46b08672efb27cf06bf2af5b9c95 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sun, 26 Oct 2025 21:05:42 +0100 Subject: [PATCH 058/267] First iteration of uncertainty dialog --- .../ui/dialogs/uncertainty_dialog.py | 464 ++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 activity_browser/ui/dialogs/uncertainty_dialog.py diff --git a/activity_browser/ui/dialogs/uncertainty_dialog.py b/activity_browser/ui/dialogs/uncertainty_dialog.py new file mode 100644 index 000000000..9a8fb83d5 --- /dev/null +++ b/activity_browser/ui/dialogs/uncertainty_dialog.py @@ -0,0 +1,464 @@ +from __future__ import annotations + +from logging import getLogger +from typing import Optional, Tuple + +import numpy as np +import seaborn as sns + +from qtpy import QtCore, QtGui, QtWidgets +from stats_arrays import uncertainty_choices as uncertainty +from stats_arrays.distributions import * # noqa: F401,F403 - mirror wizard usage + +from ...ui.widgets.plot import ABPlot +from ...bwutils.uncertainty import EMPTY_UNCERTAINTY + +log = getLogger(__name__) + + +class UncertaintyDialog(QtWidgets.QDialog): + """Single-step dialog for defining a stats_arrays uncertainty. + + Mirrors the behavior of the UncertaintyWizard type page but returns a + stats_arrays structured array on accept. + + Usage: + ok, array = UncertaintyDialog.get_uncertainty(parent, initial=dict(...)) + if ok: + # array is a numpy structured array compatible with stats_arrays + """ + + def __init__(self, parent=None, initial: Optional[dict] = None): + super().__init__(parent) + self.setWindowTitle("Set Uncertainty") + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + # State + self.dist = None + self.result_array = None # Filled on accept + self.previous_dist_id: Optional[int] = None + self.mean_is_calculated = { + TriangularUncertainty.id, + UniformUncertainty.id, + DiscreteUniform.id, + BetaUncertainty.id, + } + + # Top: distribution selection + box1 = QtWidgets.QGroupBox("Select the uncertainty distribution") + self.distribution = QtWidgets.QComboBox(box1) + self.distribution.addItems([ud.description for ud in uncertainty.choices]) + self.distribution.currentIndexChanged.connect(self._on_distribution_changed) + + header_layout = QtWidgets.QGridLayout() + header_layout.addWidget(QtWidgets.QLabel("Distribution:"), 0, 0) + header_layout.addWidget(self.distribution, 0, 1) + box1.setLayout(header_layout) + + # Middle: parameters + self.fields_box = QtWidgets.QGroupBox("Fill out required parameters") + self.locale = QtCore.QLocale( + QtCore.QLocale.English, QtCore.QLocale.UnitedStates + ) + self.locale.setNumberOptions(QtCore.QLocale.RejectGroupSeparator) + self.validator = QtGui.QDoubleValidator() + self.validator.setLocale(self.locale) + + # loc/mean + self.loc_label = QtWidgets.QLabel("Loc:") + self.loc = QtWidgets.QLineEdit() + self.loc.setValidator(self.validator) + self.loc.textEdited.connect(self._sync_mean_from_loc) + self.loc.textEdited.connect(self._check_negative) + self.loc.textEdited.connect(self._generate_plot) + + self.mean_label = QtWidgets.QLabel("Mean:") + self.mean = QtWidgets.QLineEdit() + self.mean.setValidator(self.validator) + self.mean.textEdited.connect(self._sync_loc_from_mean) + self.mean.textEdited.connect(self._check_negative) + self.mean.textEdited.connect(self._generate_plot) + + # Calculated mean (read-only) for some dists + self.calc_mean_label = QtWidgets.QLabel("Mean:") + self.calc_mean = QtWidgets.QLineEdit("nan") + self.calc_mean.setDisabled(True) + + # Other parameters + self.scale_label = QtWidgets.QLabel("Sigma/scale:") + self.scale = QtWidgets.QLineEdit() + self.scale.setValidator(self.validator) + self.scale.textEdited.connect(self._generate_plot) + + self.shape_label = QtWidgets.QLabel("Shape:") + self.shape = QtWidgets.QLineEdit() + self.shape.setValidator(self.validator) + self.shape.textEdited.connect(self._generate_plot) + + self.min_label = QtWidgets.QLabel("Minimum:") + self.minimum = QtWidgets.QLineEdit() + self.minimum.setValidator(self.validator) + self.minimum.textEdited.connect(self._generate_plot) + + self.max_label = QtWidgets.QLabel("Maximum:") + self.maximum = QtWidgets.QLineEdit() + self.maximum.setValidator(self.validator) + self.maximum.textEdited.connect(self._generate_plot) + + # Hidden flag for negative mean on lognormal + self.negative = QtWidgets.QRadioButton(self) + self.negative.setChecked(False) + self.negative.setHidden(True) + + params_layout = QtWidgets.QGridLayout() + # row 0: read-only calculated mean (will be hidden for most dists) + params_layout.addWidget(self.calc_mean_label, 0, 0) + params_layout.addWidget(self.calc_mean, 0, 1) + # row 1: loc/mean pair + params_layout.addWidget(self.loc_label, 1, 0) + params_layout.addWidget(self.loc, 1, 1) + params_layout.addWidget(self.mean_label, 1, 3) + params_layout.addWidget(self.mean, 1, 4) + # row 2+: other params + params_layout.addWidget(self.scale_label, 2, 0) + params_layout.addWidget(self.scale, 2, 1) + params_layout.addWidget(self.shape_label, 3, 0) + params_layout.addWidget(self.shape, 3, 1) + params_layout.addWidget(self.min_label, 4, 0) + params_layout.addWidget(self.minimum, 4, 1) + params_layout.addWidget(self.max_label, 5, 0) + params_layout.addWidget(self.maximum, 5, 1) + self.fields_box.setLayout(params_layout) + + # Bottom: plot + self.plot = SimpleDistributionPlot(self) + + # Buttons + self.buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + self.buttons.accepted.connect(self._on_accept) + self.buttons.rejected.connect(self.reject) + + # Layout + layout = QtWidgets.QVBoxLayout() + layout.addWidget(box1) + layout.addWidget(self.fields_box) + layout.addWidget(self.plot) + layout.addWidget(self.buttons) + self.setLayout(layout) + + # Initialize values (defaults or provided initial) + self._apply_initial(initial or {}) + self._on_distribution_changed(self.distribution.currentIndex()) + self._sync_mean_from_loc() + self._generate_plot() + + # ---------- Public API ---------- + @staticmethod + def get_uncertainty( + parent=None, initial: Optional[dict] = None + ) -> Tuple[bool, Optional[np.ndarray]]: + dlg = UncertaintyDialog(parent, initial=initial) + ok = dlg.exec_() == QtWidgets.QDialog.Accepted + return ok, dlg.result_array if ok else None + + # ---------- Internal helpers ---------- + def _apply_initial(self, initial: dict) -> None: + # Use EMPTY_UNCERTAINTY defaults, overridden by initial + data = {k: v for k, v in EMPTY_UNCERTAINTY.items()} + data.update(initial or {}) + # Distribution + try: + uc_type = int(data.get("uncertainty type", 0)) + except Exception: + uc_type = 0 + self.distribution.setCurrentIndex(uc_type) + # Fields (string form for QLineEdit) + def to_str(val): + return "nan" if val is None or (isinstance(val, float) and np.isnan(val)) else str(val) + + self.loc.setText(to_str(data.get("loc", np.nan))) + self.scale.setText(to_str(data.get("scale", np.nan))) + self.shape.setText(to_str(data.get("shape", np.nan))) + self.minimum.setText(to_str(data.get("minimum", np.nan))) + self.maximum.setText(to_str(data.get("maximum", np.nan))) + self._check_negative() + + @property + def _distribution_loc_label(self) -> str: + if self.dist.id == LognormalUncertainty.id: + return "Loc (ln(mean)):" + elif self.dist.id == TriangularUncertainty.id: + return "Mode:" + elif self.dist.id == BetaUncertainty.id: + return "Loc / alpha:" + elif self.dist.id in {GammaUncertainty.id, WeibullUncertainty.id}: + return "Loc / offset:" + else: + return "Mean:" + + def _hide_params(self, *params, hide: bool = True) -> None: + if "loc" in params: + self.loc_label.setHidden(hide) + self.loc.setHidden(hide) + if "scale" in params: + self.scale_label.setHidden(hide) + self.scale.setHidden(hide) + if "shape" in params: + self.shape_label.setHidden(hide) + self.shape.setHidden(hide) + if "min" in params: + self.min_label.setHidden(hide) + self.minimum.setHidden(hide) + if "max" in params: + self.max_label.setHidden(hide) + self.maximum.setHidden(hide) + + def _on_distribution_changed(self, index: int) -> None: + self.dist = uncertainty.id_dict[index] + + # Show/hide fields per distribution (mirror wizard) + if self.dist.id in {0, 1}: # Undefined / NoUncertainty + self._hide_params("loc", "scale", "shape", "min", "max") + elif self.dist.id in {2, 3}: # Normal / Lognormal + self._hide_params("shape", "min", "max") + self._hide_params("loc", "scale", hide=False) + elif self.dist.id in {4, 7}: # Uniform / DiscreteUniform + self._hide_params("loc", "scale", "shape") + self._hide_params("min", "max", hide=False) + elif self.dist.id in {5, 6}: # Triangular / Bernoulli-like (min/max/loc) + self._hide_params("scale", "shape") + self._hide_params("loc", "min", "max", hide=False) + elif self.dist.id in {8, 9, 10, 11, 12}: # Other 3-param + self._hide_params("min", "max") + self._hide_params("loc", "scale", "shape", hide=False) + + # Special handling (lognormal and calculated mean label) + if self.dist.id == LognormalUncertainty.id: + self.mean.setHidden(False) + self.mean_label.setHidden(False) + # Convert existing loc to log-space if coming from non-lognormal + if self.previous_dist_id is not None and self.previous_dist_id != LognormalUncertainty.id: + self._extract_lognormal_loc_from_mean() + self._sync_mean_from_loc() + else: + self.mean.setHidden(True) + self.mean_label.setHidden(True) + # If switching away from lognormal, set loc to linear amount if mean present + if self.previous_dist_id == LognormalUncertainty.id: + try: + mean_val = float(self.mean.text()) if self.mean.text() else np.nan + if not np.isnan(mean_val): + self.loc.setText(str(mean_val)) + except Exception: + pass + + # Calculated mean visibility + show_calc = self.dist.id in self.mean_is_calculated + self.calc_mean_label.setHidden(not show_calc) + self.calc_mean.setHidden(not show_calc) + + # Update labels + self.loc_label.setText(self._distribution_loc_label) + self.previous_dist_id = self.dist.id + self.fields_box.updateGeometry() + + # Update plot and OK state + self._generate_plot() + self._update_ok_state() + + def _extract_lognormal_loc_from_mean(self) -> None: + """Set loc to ln(mean) when switching to lognormal, if mean is known.""" + try: + mtxt = self.mean.text().strip() + if not mtxt: + return + val = float(mtxt) + if val == 0: + self.loc.setText("nan") + else: + val = -1 * val if val < 0 else val + self.loc.setText(str(np.log(val))) + except Exception: + self.loc.setText("nan") + + def _sync_mean_from_loc(self) -> None: + if not self.loc.text(): + return + try: + self.mean.setText(str(np.exp(float(self.loc.text())))) + except Exception: + self.mean.setText("nan") + self._update_ok_state() + + def _sync_loc_from_mean(self) -> None: + if not self.mean.hasAcceptableInput(): + self.loc.setText("nan") + self._update_ok_state() + return + try: + val = float(self.mean.text()) if self.mean.text() else float("nan") + except Exception: + val = float("nan") + if np.isnan(val) or val == 0: + self.loc.setText("nan") + else: + val = -1 * val if val < 0 else val + self.loc.setText(str(np.log(val))) + self._update_ok_state() + + def _check_negative(self) -> None: + # Special case for lognormal negative mean + try: + if not self.mean.hasAcceptableInput(): + return + val = float(self.mean.text()) if self.mean.text() else float("nan") + except Exception: + val = float("nan") + self.negative.setChecked(bool(not np.isnan(val) and val < 0)) + + def _standard_dist_fields(self, dist_id: int) -> list: + if dist_id in {2, 3}: + return ["loc", "scale"] + elif dist_id in {4, 7}: + return ["minimum", "maximum"] + elif dist_id in {5, 6}: + return ["loc", "minimum", "maximum"] + elif dist_id in {8, 9, 10, 11, 12}: + return ["loc", "scale", "shape"] + else: + return [] + + @property + def _uncertainty_info(self) -> dict: + data = {k: v for k, v in EMPTY_UNCERTAINTY.items()} + data["uncertainty type"] = self.distribution.currentIndex() + data["negative"] = bool(self.negative.isChecked()) + # Pull values from widgets + def as_float(txt: str) -> float: + try: + val = float(txt) + return val + except Exception: + return float("nan") + + for field in self._standard_dist_fields(data["uncertainty type"]): + widget = { + "loc": self.loc, + "scale": self.scale, + "shape": self.shape, + "minimum": self.minimum, + "maximum": self.maximum, + }[field] + data[field] = as_float(widget.text()) + return data + + def _completed_active_fields(self) -> bool: + # Mirror wizard validations + dist_id = self.dist.id + def ok_lineedit(le: QtWidgets.QLineEdit) -> bool: + return bool(le.hasAcceptableInput() and le.text()) + + if dist_id in {0, 1}: + return True + elif dist_id in {2, 3}: + return ok_lineedit(self.loc) and ok_lineedit(self.scale) + elif dist_id in {4, 7}: + return ok_lineedit(self.minimum) and ok_lineedit(self.maximum) + elif dist_id in {5, 6}: + if not (ok_lineedit(self.minimum) and ok_lineedit(self.maximum) and ok_lineedit(self.loc)): + return False + try: + return float(self.minimum.text()) < float(self.loc.text()) < float(self.maximum.text()) + except Exception: + return False + elif dist_id in {8, 9, 10, 11, 12}: + return ok_lineedit(self.scale) and ok_lineedit(self.shape) and ok_lineedit(self.loc) + return False + + def _update_ok_state(self) -> None: + ok_btn = self.buttons.button(QtWidgets.QDialogButtonBox.Ok) + ok_btn.setEnabled(self._completed_active_fields()) + + def _generate_plot(self) -> None: + # Update calculated mean if applicable and render sample + if self.dist is None: + return + complete = self._completed_active_fields() or self.dist.id in {UndefinedUncertainty.id, NoUncertainty.id} + if not complete: + self._update_ok_state() + return + array = self.dist.from_dicts(self._uncertainty_info) + # Calculated mean display for specific distributions + if self.dist.id in self.mean_is_calculated: + try: + calc = self.dist.statistics(array).get("mean") + except TypeError: + # DiscreteUniform workaround + array = self.dist.fix_nan_minimum(array) + calc = (array["maximum"] + array["minimum"]) / 2 + calc = calc.mean() if isinstance(calc, np.ndarray) else calc + self.calc_mean.setText(str(float(calc))) + # Vertical line value + if self.dist.id == LognormalUncertainty.id: + vline = self.dist.statistics(array).get("median") + elif self.dist.id in {UndefinedUncertainty.id, NoUncertainty.id}: + # Best effort: use loc as "mean" placeholder + try: + vline = float(self.loc.text()) if self.loc.text() else np.nan + except Exception: + vline = np.nan + else: + vline = self.dist.statistics(array).get("mean") + # Sample data + data = self.dist.random_variables(array, 1000) + if not np.any(np.isnan(data)): + try: + self.plot.plot(data, vline) + except RuntimeError as e: + log.error("%s: plotting failed, retry without KDE", e) + try: + sns.histplot(data.T, kde=False, stat="density", ax=self.plot.ax, edgecolor="none") + self.plot.ax.axvline(vline, label="Mean / amount", c="r", ymax=0.98) + self.plot.ax.legend(loc="upper right") + self.plot.canvas.draw() + except Exception: + pass + self._update_ok_state() + + def _on_accept(self) -> None: + try: + self.result_array = self.dist.from_dicts(self._uncertainty_info) + except Exception as e: + QtWidgets.QMessageBox.warning( + self, + "Invalid uncertainty", + str(e), + QtWidgets.QMessageBox.Ok, + QtWidgets.QMessageBox.Ok, + ) + return + self.accept() + + +class SimpleDistributionPlot(ABPlot): + def plot(self, data: np.ndarray, mean: float, label: str = "Value"): + self.reset_plot() + try: + sns.histplot(data.T, kde=True, stat="density", ax=self.ax, edgecolor="none") + except RuntimeError as e: + log.error("%s: Plotting without KDE.", e) + sns.histplot(data.T, kde=False, stat="density", ax=self.ax, edgecolor="none") + self.ax.set_xlabel(label) + self.ax.set_ylabel("Probability density") + # Add vertical line at given mean of x-axis + self.ax.axvline(mean, label="Mean / amount", c="r", ymax=0.98) + self.ax.legend(loc="upper right") + _, height = self.canvas.get_width_height() + self.setMinimumHeight(height / 2) + self.canvas.draw() + + +__all__ = ["UncertaintyDialog"] + From f07e9f795a05a292791eb3bb6ae86520be2bf89d Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 28 Oct 2025 09:03:08 +0100 Subject: [PATCH 059/267] Second iteration of uncertainty dialog --- .../exchange/exchange_uncertainty_modify.py | 12 +++-- activity_browser/ui/dialogs/__init__.py | 1 + .../ui/dialogs/uncertainty_dialog.py | 49 +++++++++++-------- 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/activity_browser/actions/exchange/exchange_uncertainty_modify.py b/activity_browser/actions/exchange/exchange_uncertainty_modify.py index 3da988b2a..55289f19b 100644 --- a/activity_browser/actions/exchange/exchange_uncertainty_modify.py +++ b/activity_browser/actions/exchange/exchange_uncertainty_modify.py @@ -3,8 +3,7 @@ from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -from activity_browser.ui.dialogs import UncertaintyWizard - +from activity_browser.ui.dialogs import UncertaintyDialog class ExchangeUncertaintyModify(ABAction): """ @@ -17,4 +16,11 @@ class ExchangeUncertaintyModify(ABAction): @staticmethod @exception_dialogs def run(exchanges: List[Any]): - UncertaintyWizard(exchanges[0], application.main_window).show() + + ok, array = UncertaintyDialog.get_uncertainty( + parent=application.main_window, + initial=exchanges[0].get("uncertainty", {}) + ) + + if not ok: + return diff --git a/activity_browser/ui/dialogs/__init__.py b/activity_browser/ui/dialogs/__init__.py index c9a7650e2..d50ef14d4 100644 --- a/activity_browser/ui/dialogs/__init__.py +++ b/activity_browser/ui/dialogs/__init__.py @@ -4,4 +4,5 @@ from .uncertainty import UncertaintyWizard from .new_node_dialog import NewNodeDialog from .progress_dialog import ABProgressDialog +from .uncertainty_dialog import UncertaintyDialog diff --git a/activity_browser/ui/dialogs/uncertainty_dialog.py b/activity_browser/ui/dialogs/uncertainty_dialog.py index 9a8fb83d5..bf2cef48a 100644 --- a/activity_browser/ui/dialogs/uncertainty_dialog.py +++ b/activity_browser/ui/dialogs/uncertainty_dialog.py @@ -7,15 +7,24 @@ import seaborn as sns from qtpy import QtCore, QtGui, QtWidgets -from stats_arrays import uncertainty_choices as uncertainty -from stats_arrays.distributions import * # noqa: F401,F403 - mirror wizard usage +import stats_arrays as sa -from ...ui.widgets.plot import ABPlot -from ...bwutils.uncertainty import EMPTY_UNCERTAINTY +from activity_browser.ui.widgets import ABPlot log = getLogger(__name__) +EMPTY_UNCERTAINTY = { + "uncertainty type": sa.UndefinedUncertainty.id, + "loc": np.NaN, + "scale": np.NaN, + "shape": np.NaN, + "minimum": np.NaN, + "maximum": np.NaN, + "negative": False, +} + + class UncertaintyDialog(QtWidgets.QDialog): """Single-step dialog for defining a stats_arrays uncertainty. @@ -38,16 +47,16 @@ def __init__(self, parent=None, initial: Optional[dict] = None): self.result_array = None # Filled on accept self.previous_dist_id: Optional[int] = None self.mean_is_calculated = { - TriangularUncertainty.id, - UniformUncertainty.id, - DiscreteUniform.id, - BetaUncertainty.id, + sa.TriangularUncertainty.id, + sa.UniformUncertainty.id, + sa.DiscreteUniform.id, + sa.BetaUncertainty.id, } # Top: distribution selection box1 = QtWidgets.QGroupBox("Select the uncertainty distribution") self.distribution = QtWidgets.QComboBox(box1) - self.distribution.addItems([ud.description for ud in uncertainty.choices]) + self.distribution.addItems([ud.description for ud in sa.uncertainty_choices]) self.distribution.currentIndexChanged.connect(self._on_distribution_changed) header_layout = QtWidgets.QGridLayout() @@ -187,13 +196,13 @@ def to_str(val): @property def _distribution_loc_label(self) -> str: - if self.dist.id == LognormalUncertainty.id: + if self.dist.id == sa.LognormalUncertainty.id: return "Loc (ln(mean)):" - elif self.dist.id == TriangularUncertainty.id: + elif self.dist.id == sa.TriangularUncertainty.id: return "Mode:" - elif self.dist.id == BetaUncertainty.id: + elif self.dist.id == sa.BetaUncertainty.id: return "Loc / alpha:" - elif self.dist.id in {GammaUncertainty.id, WeibullUncertainty.id}: + elif self.dist.id in {sa.GammaUncertainty.id, sa.WeibullUncertainty.id}: return "Loc / offset:" else: return "Mean:" @@ -216,7 +225,7 @@ def _hide_params(self, *params, hide: bool = True) -> None: self.maximum.setHidden(hide) def _on_distribution_changed(self, index: int) -> None: - self.dist = uncertainty.id_dict[index] + self.dist = sa.uncertainty.id_dict[index] # Show/hide fields per distribution (mirror wizard) if self.dist.id in {0, 1}: # Undefined / NoUncertainty @@ -235,18 +244,18 @@ def _on_distribution_changed(self, index: int) -> None: self._hide_params("loc", "scale", "shape", hide=False) # Special handling (lognormal and calculated mean label) - if self.dist.id == LognormalUncertainty.id: + if self.dist.id == sa.LognormalUncertainty.id: self.mean.setHidden(False) self.mean_label.setHidden(False) # Convert existing loc to log-space if coming from non-lognormal - if self.previous_dist_id is not None and self.previous_dist_id != LognormalUncertainty.id: + if self.previous_dist_id is not None and self.previous_dist_id != sa.LognormalUncertainty.id: self._extract_lognormal_loc_from_mean() self._sync_mean_from_loc() else: self.mean.setHidden(True) self.mean_label.setHidden(True) # If switching away from lognormal, set loc to linear amount if mean present - if self.previous_dist_id == LognormalUncertainty.id: + if self.previous_dist_id == sa.LognormalUncertainty.id: try: mean_val = float(self.mean.text()) if self.mean.text() else np.nan if not np.isnan(mean_val): @@ -385,7 +394,7 @@ def _generate_plot(self) -> None: # Update calculated mean if applicable and render sample if self.dist is None: return - complete = self._completed_active_fields() or self.dist.id in {UndefinedUncertainty.id, NoUncertainty.id} + complete = self._completed_active_fields() or self.dist.id in {sa.UndefinedUncertainty.id, sa.NoUncertainty.id} if not complete: self._update_ok_state() return @@ -401,9 +410,9 @@ def _generate_plot(self) -> None: calc = calc.mean() if isinstance(calc, np.ndarray) else calc self.calc_mean.setText(str(float(calc))) # Vertical line value - if self.dist.id == LognormalUncertainty.id: + if self.dist.id == sa.LognormalUncertainty.id: vline = self.dist.statistics(array).get("median") - elif self.dist.id in {UndefinedUncertainty.id, NoUncertainty.id}: + elif self.dist.id in {sa.UndefinedUncertainty.id, sa.NoUncertainty.id}: # Best effort: use loc as "mean" placeholder try: vline = float(self.loc.text()) if self.loc.text() else np.nan From 94b67c72fd0adde90e48cc4a5dae239d1f1556a0 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 28 Oct 2025 14:17:51 +0100 Subject: [PATCH 060/267] Refactor UncertaintyDialog to add get_uncertainty_dict method and update ExchangeUncertaintyModify to use it --- .../exchange/exchange_uncertainty_modify.py | 15 +++++++++++---- activity_browser/ui/dialogs/uncertainty_dialog.py | 14 ++++++++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/activity_browser/actions/exchange/exchange_uncertainty_modify.py b/activity_browser/actions/exchange/exchange_uncertainty_modify.py index 55289f19b..7d7fba3c7 100644 --- a/activity_browser/actions/exchange/exchange_uncertainty_modify.py +++ b/activity_browser/actions/exchange/exchange_uncertainty_modify.py @@ -1,5 +1,7 @@ from typing import Any, List +import bw2data as bd + from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -15,12 +17,17 @@ class ExchangeUncertaintyModify(ABAction): @staticmethod @exception_dialogs - def run(exchanges: List[Any]): - - ok, array = UncertaintyDialog.get_uncertainty( + def run(exchanges: List[bd.Edge]): + + ok, uc_dict = UncertaintyDialog.get_uncertainty_dict( parent=application.main_window, - initial=exchanges[0].get("uncertainty", {}) + initial=exchanges[0].uncertainty, ) if not ok: return + + for exchange in exchanges: + for key, value in uc_dict.items(): + exchange[key] = value + exchange.save() diff --git a/activity_browser/ui/dialogs/uncertainty_dialog.py b/activity_browser/ui/dialogs/uncertainty_dialog.py index bf2cef48a..0f5722681 100644 --- a/activity_browser/ui/dialogs/uncertainty_dialog.py +++ b/activity_browser/ui/dialogs/uncertainty_dialog.py @@ -45,6 +45,7 @@ def __init__(self, parent=None, initial: Optional[dict] = None): # State self.dist = None self.result_array = None # Filled on accept + self.result_dict = None # Filled on accept self.previous_dist_id: Optional[int] = None self.mean_is_calculated = { sa.TriangularUncertainty.id, @@ -165,12 +166,20 @@ def __init__(self, parent=None, initial: Optional[dict] = None): # ---------- Public API ---------- @staticmethod - def get_uncertainty( + def get_uncertainty_array( parent=None, initial: Optional[dict] = None ) -> Tuple[bool, Optional[np.ndarray]]: dlg = UncertaintyDialog(parent, initial=initial) ok = dlg.exec_() == QtWidgets.QDialog.Accepted return ok, dlg.result_array if ok else None + + @staticmethod + def get_uncertainty_dict( + parent=None, initial: Optional[dict] = None + ) -> Tuple[bool, Optional[dict]]: + dlg = UncertaintyDialog(parent, initial=initial) + ok = dlg.exec_() == QtWidgets.QDialog.Accepted + return ok, dlg.result_dict if ok else None # ---------- Internal helpers ---------- def _apply_initial(self, initial: dict) -> None: @@ -225,7 +234,7 @@ def _hide_params(self, *params, hide: bool = True) -> None: self.maximum.setHidden(hide) def _on_distribution_changed(self, index: int) -> None: - self.dist = sa.uncertainty.id_dict[index] + self.dist = sa.uncertainty_choices[index] # Show/hide fields per distribution (mirror wizard) if self.dist.id in {0, 1}: # Undefined / NoUncertainty @@ -438,6 +447,7 @@ def _generate_plot(self) -> None: def _on_accept(self) -> None: try: + self.result_dict = self._uncertainty_info self.result_array = self.dist.from_dicts(self._uncertainty_info) except Exception as e: QtWidgets.QMessageBox.warning( From 61c958ce6d7ae150703ceab6766d35a64ae8e2ca Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 28 Oct 2025 15:01:16 +0100 Subject: [PATCH 061/267] Refactor CFUncertaintyModify to replace UncertaintyWizard with UncertaintyDialog for improved uncertainty handling --- .../actions/method/cf_uncertainty_modify.py | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/activity_browser/actions/method/cf_uncertainty_modify.py b/activity_browser/actions/method/cf_uncertainty_modify.py index 564c96eb7..92d82a502 100644 --- a/activity_browser/actions/method/cf_uncertainty_modify.py +++ b/activity_browser/actions/method/cf_uncertainty_modify.py @@ -5,12 +5,12 @@ from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons -from activity_browser.ui.dialogs import UncertaintyWizard +from activity_browser.ui.dialogs import UncertaintyDialog class CFUncertaintyModify(ABAction): """ - ABAction to launch the UncertaintyWizard for Characterization Factor and handles the output by writing the + ABAction to launch the UncertaintyDialog for Characterization Factor and handles the output by writing the uncertainty data using the ImpactCategoryController to the Characterization Factor in question. """ @@ -20,23 +20,27 @@ class CFUncertaintyModify(ABAction): @classmethod @exception_dialogs def run(cls, method_name: tuple, char_factors: List[tuple]): - wizard = UncertaintyWizard(char_factors[0], application.main_window) - wizard.complete.connect(partial(cls.wizard_done, method_name)) - wizard.show() - @staticmethod - def wizard_done(method_name: tuple, cf: tuple, uncertainty: dict): - """Update the CF with new uncertainty information, possibly converting - the second item in the tuple to a dictionary without losing information. - """ + initial = char_factors[0][1] + initial = initial if isinstance(initial, dict) else {} + + ok, uc_dict = UncertaintyDialog.get_uncertainty_dict( + parent=application.main_window, + initial=initial, + ) + + if not ok: + return + method = bd.Method(method_name) method_dict = {cf[0]: cf[1] for cf in method.load()} - if isinstance(cf[1], dict): - cf[1].update(uncertainty) - method_dict[cf[0]] = cf[1] - else: - uncertainty["amount"] = cf[1] - method_dict[cf[0]] = uncertainty - + for cf in char_factors: + if isinstance(cf[1], dict): + cf[1].update(uc_dict) + method_dict[cf[0]] = cf[1] + else: + uc_dict["amount"] = cf[1] + method_dict[cf[0]] = uc_dict + method.write(list(method_dict.items())) From 6d5fa5c9c5d3c5022b24d74ae7dcf4fc1bedd5ae Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 28 Oct 2025 15:01:42 +0100 Subject: [PATCH 062/267] Fix import statement in MethodNew to use dialogs module instead of widgets --- .../actions/exchange/exchange_uncertainty_modify.py | 1 + activity_browser/actions/method/method_new.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/activity_browser/actions/exchange/exchange_uncertainty_modify.py b/activity_browser/actions/exchange/exchange_uncertainty_modify.py index 7d7fba3c7..86d23cf03 100644 --- a/activity_browser/actions/exchange/exchange_uncertainty_modify.py +++ b/activity_browser/actions/exchange/exchange_uncertainty_modify.py @@ -7,6 +7,7 @@ from activity_browser.ui.icons import qicons from activity_browser.ui.dialogs import UncertaintyDialog + class ExchangeUncertaintyModify(ABAction): """ ABAction to open the UncertaintyWizard for an exchange diff --git a/activity_browser/actions/method/method_new.py b/activity_browser/actions/method/method_new.py index ed6d6ee24..962e4944a 100644 --- a/activity_browser/actions/method/method_new.py +++ b/activity_browser/actions/method/method_new.py @@ -6,7 +6,7 @@ from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons -from activity_browser.ui import widgets +from activity_browser.ui import dialogs from .method_open import MethodOpen @@ -37,7 +37,7 @@ class MethodNew(ABAction): @exception_dialogs def run(): # Open dialog to get new method name - dialog = widgets.ABListEditDialog(("New Impact Category",), parent=application.main_window) + dialog = dialogs.ABListEditDialog(("New Impact Category",), parent=application.main_window) dialog.setWindowTitle("New Impact Category") if dialog.exec_() != QtWidgets.QDialog.Accepted: From 8f5624df429471ec4262affec8f203961a3a1738 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 28 Oct 2025 15:55:07 +0100 Subject: [PATCH 063/267] Enhance uncertainty modification handling in ParameterUncertaintyModify and update item references in UncertaintyDelegate --- .../parameter/parameter_uncertainty_modify.py | 19 +++++++++++++++++-- .../impact_category_details.py | 4 ++-- activity_browser/ui/delegates/uncertainty.py | 13 +++++++------ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/activity_browser/actions/parameter/parameter_uncertainty_modify.py b/activity_browser/actions/parameter/parameter_uncertainty_modify.py index b18598976..209872fa1 100644 --- a/activity_browser/actions/parameter/parameter_uncertainty_modify.py +++ b/activity_browser/actions/parameter/parameter_uncertainty_modify.py @@ -1,7 +1,10 @@ from typing import Any +import bw2data as bd + from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd +from activity_browser import application +from activity_browser.ui.dialogs import UncertaintyDialog from activity_browser.ui.icons import qicons @@ -15,7 +18,19 @@ class ParameterUncertaintyModify(ABAction): @staticmethod @exception_dialogs - def run(parameter: Any, uncertainty_dict: dict): + def run(parameter: Any, uncertainty_dict: dict=None) -> None: + + if not uncertainty_dict: + initial = parameter.dict.copy() if "uncertainty type" in parameter.dict else None + + ok, uncertainty_dict = UncertaintyDialog.get_uncertainty_dict( + parent=application.main_window, + initial=initial, + ) + + if not ok: + return + parameter.data.update(uncertainty_dict) parameter.save() bd.parameters.recalculate() diff --git a/activity_browser/layouts/pages/impact_category_details/impact_category_details.py b/activity_browser/layouts/pages/impact_category_details/impact_category_details.py index 7f11a6be7..dad194d37 100644 --- a/activity_browser/layouts/pages/impact_category_details/impact_category_details.py +++ b/activity_browser/layouts/pages/impact_category_details/impact_category_details.py @@ -175,7 +175,7 @@ def dropEvent(self, event): actions.CFNew.run(self.parent().name, biosphere_keys) -class ExchangesItem(widgets.ABDataItem): +class CharacterizationFactorsItem(widgets.ABDataItem): def flags(self, col: int, key: str): """ Returns the item flags for the given column and key. @@ -244,4 +244,4 @@ def setData(self, col: int, key: str, value) -> bool: class CharacterizationFactorsModel(widgets.ABItemModel): - dataItemClass = ExchangesItem + dataItemClass = CharacterizationFactorsItem diff --git a/activity_browser/ui/delegates/uncertainty.py b/activity_browser/ui/delegates/uncertainty.py index 52ff819c1..c978c52eb 100644 --- a/activity_browser/ui/delegates/uncertainty.py +++ b/activity_browser/ui/delegates/uncertainty.py @@ -31,13 +31,14 @@ def displayText(self, value, locale): def createEditor(self, parent, option, index): """Simply use the wizard for updating uncertainties. Send a signal.""" - if hasattr(self.parent(), "modify_uncertainty_action"): - self.parent().modify_uncertainty_action.trigger() - elif hasattr(index.internalPointer(), "exchange"): - item = index.internalPointer() + item = index.internalPointer() + item_name = item.__class__.__name__ + + if item_name == "ParametersItem": + actions.ParameterUncertaintyModify.run(item["_parameter"].to_peewee_model()) + elif item_name == "ExchangesItem": actions.ExchangeUncertaintyModify.run([item.exchange]) - elif index.internalPointer()["_impact_category_name"] is not None: - item = index.internalPointer() + elif item_name == "CharacterizationFactorsItem": actions.CFUncertaintyModify.run( item["_impact_category_name"], [(item["_id"], item["_cf"]),] ) From 1c38ddcddeb5b8acceeef2ee9a2cdf98b8fd61ac Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 28 Oct 2025 16:32:44 +0100 Subject: [PATCH 064/267] Add MainWindow and MenuBar classes for improved UI structure and navigation --- activity_browser/__main__.py | 6 +++--- activity_browser/{ui/widgets => layouts}/main_window.py | 2 +- activity_browser/{ui => layouts}/menu_bar.py | 2 +- activity_browser/layouts/panes/databases.py | 2 +- activity_browser/layouts/panes/project_manager.py | 2 +- activity_browser/ui/widgets/__init__.py | 1 - 6 files changed, 7 insertions(+), 8 deletions(-) rename activity_browser/{ui/widgets => layouts}/main_window.py (98%) rename activity_browser/{ui => layouts}/menu_bar.py (99%) diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index 418718791..967031437 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -88,12 +88,12 @@ def load_modules(self): thread.start() def load_layout(self): - from .ui.widgets import MainWindow, CentralTabWidget - from .layouts import panes, pages + from .ui.widgets import CentralTabWidget + from .layouts import panes, pages, main_window from activity_browser.bwutils import AB_metadata from activity_browser import signals - application.main_window = MainWindow() + application.main_window = main_window.MainWindow() central_widget = CentralTabWidget(application.main_window) central_widget.addTab(pages.WelcomePage(), "Welcome") central_widget.addTab(pages.ParametersPage(), "Parameters") diff --git a/activity_browser/ui/widgets/main_window.py b/activity_browser/layouts/main_window.py similarity index 98% rename from activity_browser/ui/widgets/main_window.py rename to activity_browser/layouts/main_window.py index e5caac648..3fa89ad1c 100644 --- a/activity_browser/ui/widgets/main_window.py +++ b/activity_browser/layouts/main_window.py @@ -8,7 +8,7 @@ from activity_browser import signals, application from activity_browser.ui import icons -from activity_browser.ui.menu_bar import MenuBar +from activity_browser.layouts.menu_bar import MenuBar log = getLogger(__name__) diff --git a/activity_browser/ui/menu_bar.py b/activity_browser/layouts/menu_bar.py similarity index 99% rename from activity_browser/ui/menu_bar.py rename to activity_browser/layouts/menu_bar.py index 4406f0995..c9f02de81 100644 --- a/activity_browser/ui/menu_bar.py +++ b/activity_browser/layouts/menu_bar.py @@ -7,7 +7,7 @@ from activity_browser import actions, signals, utils, application -from .icons import qicons +from ..ui.icons import qicons class MenuBar(QtWidgets.QMenuBar): diff --git a/activity_browser/layouts/panes/databases.py b/activity_browser/layouts/panes/databases.py index 10af03971..214e4416e 100644 --- a/activity_browser/layouts/panes/databases.py +++ b/activity_browser/layouts/panes/databases.py @@ -8,7 +8,7 @@ from activity_browser import signals, actions, bwutils from activity_browser.ui import widgets, icons, delegates -from activity_browser.ui.menu_bar import ImportDatabaseMenu +from activity_browser.layouts.menu_bar import ImportDatabaseMenu class DatabasesPane(widgets.ABAbstractPane): diff --git a/activity_browser/layouts/panes/project_manager.py b/activity_browser/layouts/panes/project_manager.py index c6e88bd65..ed78ebf9c 100644 --- a/activity_browser/layouts/panes/project_manager.py +++ b/activity_browser/layouts/panes/project_manager.py @@ -97,7 +97,7 @@ class ProjectView(widgets.ABTreeView): class ContextMenu(widgets.ABTreeView.ContextMenu): def __init__(self, pos, view: "FunctionView"): - from activity_browser.ui.menu_bar import ProjectNewMenu + from activity_browser.layouts.menu_bar import ProjectNewMenu super().__init__(pos, view) items = list({index.internalPointer() for index in view.selectedIndexes()}) diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index 4ccdf99bd..f9e86f4b0 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -17,7 +17,6 @@ from .database_name_edit import DatabaseNameEdit from .dock_widget import ABDockWidget from .label import ABLabel -from .main_window import MainWindow from .central import CentralTabWidget from .menu import ABMenu from .drop_overlay import ABDropOverlay From 9dc5595538d3674e4bd1de86dfcbea58d602073d Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 28 Oct 2025 16:56:33 +0100 Subject: [PATCH 065/267] Refactor imports to use dialogs module and update MainWindow references for consistency --- activity_browser/__main__.py | 4 +- .../actions/method/method_rename.py | 4 +- activity_browser/layouts/__init__.py | 3 ++ tests/actions/test_exchange_actions.py | 37 +++++++++++++++---- tests/actions/test_method_actions.py | 4 +- tests/conftest.py | 4 +- 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index 967031437..8a6b4d7d9 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -89,11 +89,11 @@ def load_modules(self): def load_layout(self): from .ui.widgets import CentralTabWidget - from .layouts import panes, pages, main_window + from .layouts import panes, pages, MainWindow from activity_browser.bwutils import AB_metadata from activity_browser import signals - application.main_window = main_window.MainWindow() + application.main_window = MainWindow() central_widget = CentralTabWidget(application.main_window) central_widget.addTab(pages.WelcomePage(), "Welcome") central_widget.addTab(pages.ParametersPage(), "Parameters") diff --git a/activity_browser/actions/method/method_rename.py b/activity_browser/actions/method/method_rename.py index 5e2705166..ff6578a43 100644 --- a/activity_browser/actions/method/method_rename.py +++ b/activity_browser/actions/method/method_rename.py @@ -6,7 +6,7 @@ import bw2data as bd from activity_browser import application, signals -from activity_browser.ui import widgets +from activity_browser.ui import dialogs from activity_browser.actions.base import ABAction, exception_dialogs log = getLogger(__name__) @@ -47,7 +47,7 @@ def run(method_name: tuple[str] | list[tuple[str]]): method = bd.Method(method_name) # open dialog to get new name - dialog = widgets.ABListEditDialog( + dialog = dialogs.ABListEditDialog( method_name, title="Rename Impact Category", parent=application.main_window, diff --git a/activity_browser/layouts/__init__.py b/activity_browser/layouts/__init__.py index 40a96afc6..416f9e4fa 100644 --- a/activity_browser/layouts/__init__.py +++ b/activity_browser/layouts/__init__.py @@ -1 +1,4 @@ # -*- coding: utf-8 -*- +__all__ = ["panes", "pages", "MainWindow"] + +from .main_window import MainWindow diff --git a/tests/actions/test_exchange_actions.py b/tests/actions/test_exchange_actions.py index 8115099ee..1040fa36c 100644 --- a/tests/actions/test_exchange_actions.py +++ b/tests/actions/test_exchange_actions.py @@ -1,8 +1,8 @@ import pytest -from stats_arrays.distributions import NoUncertainty, UndefinedUncertainty +from stats_arrays.distributions import NoUncertainty, UndefinedUncertainty, UniformUncertainty from activity_browser import actions, application -from activity_browser.ui.dialogs import UncertaintyWizard +from activity_browser.ui.dialogs import UncertaintyDialog # def test_exchange_copy_sdf(basic_database): @@ -116,7 +116,7 @@ def test_exchange_new(basic_database): ) -def test_exchange_uncertainty_modify(basic_database): +def test_exchange_uncertainty_modify(monkeypatch, basic_database): process = basic_database.get("process") elementary = basic_database.get("elementary") @@ -126,14 +126,35 @@ def test_exchange_uncertainty_modify(basic_database): if exchange.input == elementary ] assert len(exchange) == 1 + + # Initial state: should have NoUncertainty + assert exchange[0].uncertainty_type == NoUncertainty + + # Create mock uncertainty data to be returned by the dialog + mock_uncertainty = { + "uncertainty type": UniformUncertainty.id, + "loc": float("nan"), + "scale": float("nan"), + "shape": float("nan"), + "minimum": 5.0, + "maximum": 15.0, + "negative": False, + } + + # Monkeypatch the dialog to return our mock data + monkeypatch.setattr( + UncertaintyDialog, + "get_uncertainty_dict", + lambda *args, **kwargs: (True, mock_uncertainty), + ) actions.ExchangeUncertaintyModify.run(exchange) - wizard = application.main_window.findChild(UncertaintyWizard) - - assert wizard.isVisible() - - wizard.destroy() + # Verify the exchange was updated with the new uncertainty values + assert exchange[0].uncertainty_type == UniformUncertainty + assert exchange[0]["minimum"] == 5.0 + assert exchange[0]["maximum"] == 15.0 + assert exchange[0]["negative"] == False def test_exchange_uncertainty_remove(basic_database): diff --git a/tests/actions/test_method_actions.py b/tests/actions/test_method_actions.py index bd3641854..a7465a55a 100644 --- a/tests/actions/test_method_actions.py +++ b/tests/actions/test_method_actions.py @@ -155,7 +155,7 @@ def test_method_duplicate(monkeypatch, basic_database): def test_method_new(monkeypatch, basic_database): - from activity_browser.ui.widgets import ABListEditDialog + from activity_browser.ui.dialogs import ABListEditDialog new_method = ("New Test Method", "Test Category") @@ -215,7 +215,7 @@ def test_calculation_setups_updated_on_method_delete(monkeypatch, basic_database def test_calculation_setups_updated_on_method_rename(monkeypatch, basic_database): # prepare rename dialog to accept and return new name - from activity_browser.ui.widgets import ABListEditDialog + from activity_browser.ui.dialogs import ABListEditDialog import bw2data as bd old = ("basic_method",) diff --git a/tests/conftest.py b/tests/conftest.py index f2e36a1e0..0da7af01e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,8 +7,8 @@ from bw2data.tests import bw2test from activity_browser import application -from activity_browser.ui.widgets import MainWindow, CentralTabWidget -from activity_browser.layouts import pages +from activity_browser.ui.widgets import CentralTabWidget +from activity_browser.layouts import pages, MainWindow @pytest.fixture From d0a18d95297c3fdc08d334eb28ee6ff771ff093f Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 28 Oct 2025 17:16:03 +0100 Subject: [PATCH 066/267] Fix qicons acting up in pydev debugger --- activity_browser/ui/icons.py | 92 +++++++++++++++++------------------- 1 file changed, 43 insertions(+), 49 deletions(-) diff --git a/activity_browser/ui/icons.py b/activity_browser/ui/icons.py index 011f21576..880c44f70 100644 --- a/activity_browser/ui/icons.py +++ b/activity_browser/ui/icons.py @@ -39,74 +39,68 @@ def empty_icon(size: QSize = QSize(32, 32)) -> QIcon: # switch = create_path('main', 'switch-state.png') -class Icons(object): +icons = dict( # Icons from href="https://www.flaticon.com/ # MAIN - ab = create_path("main", "activitybrowser.png") + ab = create_path("main", "activitybrowser.png"), # arrows - right = create_path("main", "right.png") - left = create_path("main", "left.png") - forward = create_path("main", "forward.png") - backward = create_path("main", "backward.png") + right = create_path("main", "right.png"), + left = create_path("main", "left.png"), + forward = create_path("main", "forward.png"), + backward = create_path("main", "backward.png"), # Simple actions - delete = create_path("context", "delete.png") - clear = create_path("context", "clear.png") - copy = create_path("context", "copy.png") - add = create_path("context", "add.png") - edit = create_path("main", "edit.png") - calculate = create_path("main", "calculate.png") - question = create_path("context", "question.png") - search = create_path("main", "search.png") - filter = create_path("main", "filter.png") - filter_outline = create_path("main", "filter_outline.png") + delete = create_path("context", "delete.png"), + clear = create_path("context", "clear.png"), + copy = create_path("context", "copy.png"), + add = create_path("context", "add.png"), + edit = create_path("main", "edit.png"), + calculate = create_path("main", "calculate.png"), + question = create_path("context", "question.png"), + search = create_path("main", "search.png"), + filter = create_path("main", "filter.png"), + filter_outline = create_path("main", "filter_outline.png"), # database - import_db = create_path("main", "import_database.png") - duplicate_database = create_path("main", "duplicate_database.png") + import_db = create_path("main", "import_database.png"), + duplicate_database = create_path("main", "duplicate_database.png"), # activity - duplicate_activity = create_path("main", "duplicate_activity.png") - duplicate_to_other_database = create_path("main", "import_database.png") - parameterized = create_path("main", "parameterized.png") + duplicate_activity = create_path("main", "duplicate_activity.png"), + duplicate_to_other_database = create_path("main", "import_database.png"), + parameterized = create_path("main", "parameterized.png"), # windows - graph_explorer = create_path("main", "graph_explorer.png") - issue = create_path("main", "idea.png") - settings = create_path("main", "settings.png") - history = create_path("main", "history.png") - welcome = create_path("main", "welcome.png") - main_window = create_path("main", "home.png") + graph_explorer = create_path("main", "graph_explorer.png"), + issue = create_path("main", "idea.png"), + settings = create_path("main", "settings.png"), + history = create_path("main", "history.png"), + welcome = create_path("main", "welcome.png"), + main_window = create_path("main", "home.png"), # plugins - plugin = create_path("main", "plugin.png") + plugin = create_path("main", "plugin.png"), # nodes - process = create_path("nodes", "process.png") - product = create_path("nodes", "product.png") - waste = create_path("nodes", "waste.png") - processproduct = create_path("nodes", "processproduct.png") - biosphere = create_path("nodes", "biosphere.png") - readonly_process = create_path("nodes", "read-only-process.png") + process = create_path("nodes", "process.png"), + product = create_path("nodes", "product.png"), + waste = create_path("nodes", "waste.png"), + processproduct = create_path("nodes", "processproduct.png"), + biosphere = create_path("nodes", "biosphere.png"), + readonly_process = create_path("nodes", "read-only-process.png"), # other - superstructure = create_path("main", "superstructure.png") - copy_to_clipboard = create_path("main", "copy_to_clipboard.png") - warning = create_path("context", "warning.png") - critical = create_path("context", "critical.png") - locked = create_path("main", "locked.png") - unlocked = create_path("main", "unlocked.png") + superstructure = create_path("main", "superstructure.png"), + copy_to_clipboard = create_path("main", "copy_to_clipboard.png"), + warning = create_path("context", "warning.png"), + critical = create_path("context", "critical.png"), + locked = create_path("main", "locked.png"), + unlocked = create_path("main", "unlocked.png"), +) -class QIcons(Icons): - """Using the Icons class, returns the same attributes, but as QIcon type""" - empty = empty_icon() +qicons = type("QIcons", (object,), {k: QIcon(v) for k, v in icons.items()}) +qicons.empty = empty_icon() - def __getattribute__(self, item): - return QIcon(Icons.__getattribute__(self, item)) - - -icons = Icons() -qicons = QIcons() From b506ec90a01ed5497c2ce8c73caee043b95a075f Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 29 Oct 2025 12:55:01 +0100 Subject: [PATCH 067/267] Replace standard logging with Loguru for improved logging functionality across the application. This includes updating all logging calls to use Loguru's logger and removing the old logging setup. Additionally, added Loguru as a dependency in the project configuration. --- activity_browser/__main__.py | 36 ++- .../actions/activity/activity_open.py | 6 +- .../activity/activity_redo_allocation.py | 8 +- .../activity/process_property_remove.py | 6 +- activity_browser/actions/base.py | 4 +- .../cs_add_functional_unit.py | 4 +- .../cs_add_impact_category.py | 4 +- .../actions/calculation_setup/cs_calculate.py | 4 +- .../cs_change_functional_unit.py | 4 +- .../actions/calculation_setup/cs_delete.py | 8 +- .../cs_delete_functional_unit.py | 4 +- .../cs_delete_impact_category.py | 4 +- .../actions/calculation_setup/cs_duplicate.py | 6 +- .../actions/calculation_setup/cs_new.py | 6 +- .../actions/calculation_setup/cs_open.py | 6 +- .../actions/calculation_setup/cs_rename.py | 6 +- .../database/database_export_bw2package.py | 10 +- .../actions/database/database_export_excel.py | 8 +- .../database_import_from_ecoinvent.py | 4 +- .../database/database_importer_bw2package.py | 4 +- .../database/database_importer_excel.py | 4 +- .../actions/database/database_open.py | 4 +- .../database/database_redo_allocation.py | 8 +- .../actions/exchange/exchange_modify.py | 4 +- .../actions/metadatastore_open.py | 4 +- .../method/importer/method_importer_bw2io.py | 4 +- .../importer/method_importer_ecoinvent.py | 4 +- .../actions/method/method_delete.py | 10 +- .../actions/method/method_duplicate.py | 6 +- .../actions/method/method_meta_modify.py | 6 +- activity_browser/actions/method/method_new.py | 6 +- .../actions/method/method_rename.py | 8 +- .../actions/parameter/parameter_modify.py | 6 +- .../project/project_create_template.py | 8 +- .../actions/project/project_export.py | 8 +- .../actions/project/project_import.py | 8 +- .../actions/project/project_local_import.py | 8 +- .../actions/project/project_migrate25.py | 10 +- .../actions/project/project_new_template.py | 8 +- .../actions/project/project_remote_import.py | 8 +- .../actions/project/project_switch.py | 10 +- activity_browser/bwutils/commontasks.py | 8 +- .../ecospold2biosphereimporter.py | 10 +- .../bwutils/io/ecoinvent_importer.py | 10 +- activity_browser/bwutils/metadata/loader.py | 16 +- activity_browser/bwutils/metadata/metadata.py | 6 +- activity_browser/bwutils/metadata/updater.py | 4 +- activity_browser/bwutils/montecarlo.py | 16 +- activity_browser/bwutils/multilca.py | 6 +- .../bwutils/sensitivity_analysis.py | 32 +-- activity_browser/bwutils/strategies.py | 14 +- .../bwutils/superstructure/excel.py | 8 +- .../bwutils/superstructure/file_imports.py | 10 +- .../bwutils/superstructure/manager.py | 12 +- .../bwutils/superstructure/utils.py | 6 +- activity_browser/info.py | 10 +- activity_browser/layouts/main_window.py | 4 +- .../activity_details/activity_details.py | 4 +- .../pages/activity_details/exchanges_tab.py | 8 +- .../pages/activity_details/graph_tab.py | 16 +- .../calculation_setup/scenario_section.py | 12 +- .../layouts/pages/lca_results/LCA_results.py | 24 +- .../layouts/pages/lca_results/plots.py | 4 +- .../layouts/pages/lca_results/tables.py | 12 +- .../layouts/pages/parameters/base.py | 12 +- .../pages/parameters/parameter_models.py | 8 +- .../layouts/panes/database_explorer.py | 4 +- .../layouts/panes/database_products.py | 8 +- .../layouts/panes/project_manager.py | 6 +- activity_browser/logger.py | 248 ------------------ activity_browser/mod/bw2io/__init__.py | 14 +- activity_browser/mod/bw2io/ecoinvent.py | 20 +- .../bw2io/importers/ecospold2_biosphere.py | 4 +- activity_browser/settings.py | 8 +- activity_browser/signals.py | 48 ++-- activity_browser/ui/core/application.py | 4 +- activity_browser/ui/core/threading.py | 36 ++- activity_browser/ui/dialogs/uncertainty.py | 8 +- .../ui/dialogs/uncertainty_dialog.py | 8 +- activity_browser/ui/web/base.py | 6 +- activity_browser/ui/web/navigator.py | 26 +- activity_browser/ui/web/sankey_navigator.py | 10 +- activity_browser/ui/web/tree_navigator.py | 10 +- activity_browser/ui/web/webengine_page.py | 12 +- activity_browser/ui/widgets/central.py | 4 +- activity_browser/ui/widgets/plot.py | 4 +- activity_browser/ui/widgets/treeview.py | 10 +- .../ui/wizards/settings_wizard.py | 12 +- pyproject.toml | 1 + 89 files changed, 420 insertions(+), 647 deletions(-) delete mode 100644 activity_browser/logger.py diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index 8a6b4d7d9..e2fe76730 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -1,6 +1,5 @@ import sys import os -from logging import getLogger from importlib import metadata import requests @@ -16,10 +15,11 @@ from activity_browser import application from activity_browser.ui import icons -from .logger import setup_ab_logging +from loguru import logger +import platformdirs from .static.icons import main -log = getLogger(__name__) + class SpecialProgressBar(QtWidgets.QWidget): @@ -119,13 +119,13 @@ class ModuleThread(QtCore.QThread): def run(self): self.status.emit("Loading Numpy") - log.debug("ABLoader: Importing numpy") + logger.debug("ABLoader: Importing numpy") import numpy, pandas self.status.emit("Loading Brightway25") - log.debug("ABLoader: Importing brightway modules") + logger.debug("ABLoader: Importing brightway modules") import bw2data, bw2calc, bw2analyzer, bw2io, bw_functional, bw_processing, matrix_utils self.status.emit("Loading Activity Browser") - log.debug("ABLoader: Importing activity_browser") + logger.debug("ABLoader: Importing activity_browser") from activity_browser import actions, layouts, mod, settings, ui, signals from activity_browser.layouts import panes, pages from activity_browser.ui import core, widgets, web, wizards @@ -144,16 +144,28 @@ def run(self): bd.projects.change_base_directories(base_dir, project_name=project_name, update=False) if not bd.projects.twofive: - log.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") + logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") actions.ProjectSwitch.set_warning_bar() - log.info(f"Brightway2 data directory: {bd.projects._base_data_dir}") - log.info(f"Brightway2 current project: {bd.projects.current}") + logger.info(f"Brightway2 data directory: {bd.projects._base_data_dir}") + logger.info(f"Brightway2 current project: {bd.projects.current}") + + +def setup_logging(): + """Configure loguru sinks for console and file logging.""" + logger.remove() + logger.add(sys.stderr, level="DEBUG", colorize=True, + format="{time:HH:mm:ss} | {level: <8} | {message}") + + log_dir = platformdirs.user_log_dir("ActivityBrowser", "ActivityBrowser") + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, "activity_browser.log") + logger.add(log_file, level="DEBUG", rotation="5 MB", retention=5) def run_activity_browser(): pre_flight_checks() - setup_ab_logging() + setup_logging() loader = ABLoader() loader.show() application.set_icon() # setting this here seems to fix the icon not showing sometimes @@ -162,7 +174,7 @@ def run_activity_browser(): def run_activity_browser_no_launcher(): pre_flight_checks() - setup_ab_logging() + setup_logging() modules = ModuleThread() modules.run() @@ -252,7 +264,7 @@ def check_pypi_update(): if "--no-launcher" in sys.argv: run_activity_browser_no_launcher() elif sys.version_info[1] == 10: - log.info("Running Activity Browser without launcher for Python 3.10") + logger.info("Running Activity Browser without launcher for Python 3.10") run_activity_browser_no_launcher() else: run_activity_browser() diff --git a/activity_browser/actions/activity/activity_open.py b/activity_browser/actions/activity/activity_open.py index 6806636c5..52f9a4a02 100644 --- a/activity_browser/actions/activity/activity_open.py +++ b/activity_browser/actions/activity/activity_open.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger import bw2data as bd import bw_functional as bf @@ -7,7 +7,7 @@ from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -log = getLogger(__name__) + class ActivityOpen(ABAction): @@ -51,7 +51,7 @@ def run(activities: list[tuple | int | bd.Node]): for act in activities: # Check if the activity type is supported if not bwutils.is_node_process(act): - log.warning(f"Can't open activity {act.key} - opening type: `{act.get('type')}` not supported") + logger.warning(f"Can't open activity {act.key} - opening type: `{act.get('type')}` not supported") continue # Create a details page for the activity diff --git a/activity_browser/actions/activity/activity_redo_allocation.py b/activity_browser/actions/activity/activity_redo_allocation.py index 621d4d19d..5f6755e54 100644 --- a/activity_browser/actions/activity/activity_redo_allocation.py +++ b/activity_browser/actions/activity/activity_redo_allocation.py @@ -1,11 +1,11 @@ from qtpy import QtGui -from logging import getLogger +from loguru import logger from activity_browser import signals from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd -log = getLogger(__name__) + class MultifunctionalProcessRedoAllocation(ABAction): """ @@ -27,9 +27,9 @@ def run(node: bd.Node): signals.new_statusbar_message.emit(f"Allocation values for process {node} updated.") except KeyError as exc: signals.new_statusbar_message.emit("A property for the allocation calculation was not found!") - log.error(f"A property for the allocation calculation was not found: {node}") + logger.error(f"A property for the allocation calculation was not found: {node}") raise exc except ZeroDivisionError as exc: signals.new_statusbar_message.emit(str(exc)) - log.error(f"Zero division in allocation calculation: {exc}") + logger.error(f"Zero division in allocation calculation: {exc}") raise exc diff --git a/activity_browser/actions/activity/process_property_remove.py b/activity_browser/actions/activity/process_property_remove.py index b42ebcc3e..29ec91d06 100644 --- a/activity_browser/actions/activity/process_property_remove.py +++ b/activity_browser/actions/activity/process_property_remove.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from activity_browser import bwutils from activity_browser.actions.base import ABAction, exception_dialogs @@ -7,7 +7,7 @@ from bw_functional import Process from bw2data import databases -log = getLogger(__name__) + class ProcessPropertyRemove(ABAction): @@ -43,7 +43,7 @@ def run(process: tuple | int | Process, property_name: str): allocate = property_name == process.get("allocation") if property_name not in process.available_properties(): - log.warning(f"Property '{property_name}' not found in process {process.key}.") + logger.warning(f"Property '{property_name}' not found in process {process.key}.") return if allocate: diff --git a/activity_browser/actions/base.py b/activity_browser/actions/base.py index 4894ff20a..a9dfbaf6f 100644 --- a/activity_browser/actions/base.py +++ b/activity_browser/actions/base.py @@ -1,9 +1,9 @@ -from logging import getLogger +from loguru import logger from qtpy import QtCore, QtGui, QtWidgets from activity_browser import application -log = getLogger(__name__) + class ABAction: diff --git a/activity_browser/actions/calculation_setup/cs_add_functional_unit.py b/activity_browser/actions/calculation_setup/cs_add_functional_unit.py index 7e71b6351..d196cdcd9 100644 --- a/activity_browser/actions/calculation_setup/cs_add_functional_unit.py +++ b/activity_browser/actions/calculation_setup/cs_add_functional_unit.py @@ -1,10 +1,10 @@ -from logging import getLogger +from loguru import logger from activity_browser import bwutils from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd -log = getLogger(__name__) + class CSAddFunctionalUnit(ABAction): diff --git a/activity_browser/actions/calculation_setup/cs_add_impact_category.py b/activity_browser/actions/calculation_setup/cs_add_impact_category.py index 059546551..2802c21c8 100644 --- a/activity_browser/actions/calculation_setup/cs_add_impact_category.py +++ b/activity_browser/actions/calculation_setup/cs_add_impact_category.py @@ -1,10 +1,10 @@ -from logging import getLogger +from loguru import logger import bw2data as bd from activity_browser.actions.base import ABAction, exception_dialogs -log = getLogger(__name__) + class CSAddImpactCategory(ABAction): diff --git a/activity_browser/actions/calculation_setup/cs_calculate.py b/activity_browser/actions/calculation_setup/cs_calculate.py index 2e3b08c61..5634365fc 100644 --- a/activity_browser/actions/calculation_setup/cs_calculate.py +++ b/activity_browser/actions/calculation_setup/cs_calculate.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger import pandas as pd import bw2data as bd @@ -9,7 +9,7 @@ from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -log = getLogger(__name__) + class CSCalculate(ABAction): diff --git a/activity_browser/actions/calculation_setup/cs_change_functional_unit.py b/activity_browser/actions/calculation_setup/cs_change_functional_unit.py index 5111c23b8..947c87043 100644 --- a/activity_browser/actions/calculation_setup/cs_change_functional_unit.py +++ b/activity_browser/actions/calculation_setup/cs_change_functional_unit.py @@ -1,10 +1,10 @@ -from logging import getLogger +from loguru import logger from activity_browser import bwutils from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd -log = getLogger(__name__) + class CSChangeFunctionalUnit(ABAction): diff --git a/activity_browser/actions/calculation_setup/cs_delete.py b/activity_browser/actions/calculation_setup/cs_delete.py index f1575354c..bf7ed2bd5 100644 --- a/activity_browser/actions/calculation_setup/cs_delete.py +++ b/activity_browser/actions/calculation_setup/cs_delete.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets @@ -7,7 +7,7 @@ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons -log = getLogger(__name__) + class CSDelete(ABAction): @@ -40,8 +40,8 @@ def run(cs_names: str | list[str]): for cs_name in cs_names: if cs_name not in bd.calculation_setups: - log.warning(f"Calculation setup {cs_name} not found") + logger.warning(f"Calculation setup {cs_name} not found") continue del bd.calculation_setups[cs_name] - log.info(f"Deleted calculation setup: {cs_name}") + logger.info(f"Deleted calculation setup: {cs_name}") diff --git a/activity_browser/actions/calculation_setup/cs_delete_functional_unit.py b/activity_browser/actions/calculation_setup/cs_delete_functional_unit.py index 2d1f4b972..5f07c1b85 100644 --- a/activity_browser/actions/calculation_setup/cs_delete_functional_unit.py +++ b/activity_browser/actions/calculation_setup/cs_delete_functional_unit.py @@ -1,10 +1,10 @@ -from logging import getLogger +from loguru import logger import bw2data as bd from activity_browser.actions.base import ABAction, exception_dialogs -log = getLogger(__name__) + class CSDeleteFunctionalUnit(ABAction): diff --git a/activity_browser/actions/calculation_setup/cs_delete_impact_category.py b/activity_browser/actions/calculation_setup/cs_delete_impact_category.py index de58fcfd0..ce5cd658f 100644 --- a/activity_browser/actions/calculation_setup/cs_delete_impact_category.py +++ b/activity_browser/actions/calculation_setup/cs_delete_impact_category.py @@ -1,10 +1,10 @@ -from logging import getLogger +from loguru import logger import bw2data as bd from activity_browser.actions.base import ABAction, exception_dialogs -log = getLogger(__name__) + class CSDeleteImpactCategory(ABAction): diff --git a/activity_browser/actions/calculation_setup/cs_duplicate.py b/activity_browser/actions/calculation_setup/cs_duplicate.py index 69a675a2e..f0ba09fc6 100644 --- a/activity_browser/actions/calculation_setup/cs_duplicate.py +++ b/activity_browser/actions/calculation_setup/cs_duplicate.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets @@ -7,7 +7,7 @@ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons -log = getLogger(__name__) + class CSDuplicate(ABAction): @@ -45,4 +45,4 @@ def run(cs_name: str): bd.calculation_setups[new_name] = bd.calculation_setups[cs_name].copy() signals.calculation_setup_selected.emit(new_name) - log.info(f"Copied calculation setup {cs_name} as {new_name}") + logger.info(f"Copied calculation setup {cs_name} as {new_name}") diff --git a/activity_browser/actions/calculation_setup/cs_new.py b/activity_browser/actions/calculation_setup/cs_new.py index 3e0d9878e..8fbd5011b 100644 --- a/activity_browser/actions/calculation_setup/cs_new.py +++ b/activity_browser/actions/calculation_setup/cs_new.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets @@ -9,7 +9,7 @@ from activity_browser.bwutils import refresh_node from activity_browser.ui.icons import qicons -log = getLogger(__name__) + class CSNew(ABAction): @@ -69,7 +69,7 @@ def run(name: str = None, # instruct the CalculationSetupController to create a CS with the new name bd.calculation_setups[name] = {"inv": inv, "ia": ia} - log.info(f"New calculation setup: {name}") + logger.info(f"New calculation setup: {name}") actions.CSOpen.run(name) diff --git a/activity_browser/actions/calculation_setup/cs_open.py b/activity_browser/actions/calculation_setup/cs_open.py index 131cc3347..bbf3bb9c8 100644 --- a/activity_browser/actions/calculation_setup/cs_open.py +++ b/activity_browser/actions/calculation_setup/cs_open.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets @@ -7,7 +7,7 @@ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons -log = getLogger(__name__) + class CSOpen(ABAction): @@ -23,7 +23,7 @@ def run(cs_names: str | list[str]): for cs_name in cs_names: if cs_name not in bd.calculation_setups: - log.warning(f"Calculation setup {cs_name} not found") + logger.warning(f"Calculation setup {cs_name} not found") continue page = pages.CalculationSetupPage(cs_name) diff --git a/activity_browser/actions/calculation_setup/cs_rename.py b/activity_browser/actions/calculation_setup/cs_rename.py index 95a638810..2079afe43 100644 --- a/activity_browser/actions/calculation_setup/cs_rename.py +++ b/activity_browser/actions/calculation_setup/cs_rename.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets @@ -7,7 +7,7 @@ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons -log = getLogger(__name__) + class CSRename(ABAction): @@ -47,4 +47,4 @@ def run(cs_name: str, new_name: str = None): bd.calculation_setups[new_name] = bd.calculation_setups[cs_name].copy() del bd.calculation_setups[cs_name] signals.calculation_setup_selected.emit(new_name) - log.info(f"Renamed calculation setup from {cs_name} to {new_name}") + logger.info(f"Renamed calculation setup from {cs_name} to {new_name}") diff --git a/activity_browser/actions/database/database_export_bw2package.py b/activity_browser/actions/database/database_export_bw2package.py index 92e475457..522ec1712 100644 --- a/activity_browser/actions/database/database_export_bw2package.py +++ b/activity_browser/actions/database/database_export_bw2package.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from typing import List from qtpy import QtWidgets @@ -9,7 +9,7 @@ from activity_browser.bwutils import exporters from activity_browser.ui.core import threading -log = getLogger(__name__) + class DatabaseExportBW2Package(ABAction): @@ -86,12 +86,12 @@ def run_safely(self, db_names: List[str], path: str): try: success = exporters.store_database_as_package(db_name, path) if success: - log.info(f"Successfully exported database '{db_name}' to BW2Package") + logger.info(f"Successfully exported database '{db_name}' to BW2Package") else: - log.error(f"Failed to export database '{db_name}'") + logger.error(f"Failed to export database '{db_name}'") raise RuntimeError(f"Database '{db_name}' not found") except Exception as e: - log.error(f"Failed to export database '{db_name}': {e}") + logger.error(f"Failed to export database '{db_name}': {e}") raise def initializePage(self, context: dict): diff --git a/activity_browser/actions/database/database_export_excel.py b/activity_browser/actions/database/database_export_excel.py index 199391750..a17225b4e 100644 --- a/activity_browser/actions/database/database_export_excel.py +++ b/activity_browser/actions/database/database_export_excel.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from typing import List from qtpy import QtWidgets @@ -10,7 +10,7 @@ from activity_browser.ui.core import threading -log = getLogger(__name__) + class DatabaseExportExcel(ABAction): @@ -86,9 +86,9 @@ def run_safely(self, db_names: List[str], path: str): for db_name in db_names: try: exporters.write_lci_excel(db_name, path) - log.info(f"Successfully exported database '{db_name}' to Excel") + logger.info(f"Successfully exported database '{db_name}' to Excel") except Exception as e: - log.error(f"Failed to export database '{db_name}': {e}") + logger.error(f"Failed to export database '{db_name}': {e}") raise def initializePage(self, context: dict): diff --git a/activity_browser/actions/database/database_import_from_ecoinvent.py b/activity_browser/actions/database/database_import_from_ecoinvent.py index d2e75d0a7..90586be83 100644 --- a/activity_browser/actions/database/database_import_from_ecoinvent.py +++ b/activity_browser/actions/database/database_import_from_ecoinvent.py @@ -1,6 +1,6 @@ import re import os -from logging import getLogger +from loguru import logger from copy import deepcopy import requests @@ -20,7 +20,7 @@ from activity_browser.mod.bw2io.migrations import ab_create_core_migrations from activity_browser.ui.core import threading -log = getLogger(__name__) + class DatabaseImportFromEcoinvent(ABAction): diff --git a/activity_browser/actions/database/database_importer_bw2package.py b/activity_browser/actions/database/database_importer_bw2package.py index 3386d2e4f..d0bafbb2f 100644 --- a/activity_browser/actions/database/database_importer_bw2package.py +++ b/activity_browser/actions/database/database_importer_bw2package.py @@ -1,5 +1,5 @@ import os -from logging import getLogger +from loguru import logger from qtpy import QtWidgets @@ -9,7 +9,7 @@ from activity_browser.bwutils.importers import ABPackage from activity_browser.ui.core import threading -log = getLogger(__name__) + class DatabaseImporterBW2Package(ABAction): diff --git a/activity_browser/actions/database/database_importer_excel.py b/activity_browser/actions/database/database_importer_excel.py index 5226aef93..ac66b116d 100644 --- a/activity_browser/actions/database/database_importer_excel.py +++ b/activity_browser/actions/database/database_importer_excel.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets from qtpy.QtCore import Signal, SignalInstance @@ -11,7 +11,7 @@ from activity_browser.bwutils.importers import ABExcelImporter from activity_browser.ui.core import threading -log = getLogger(__name__) + class DatabaseImporterExcel(ABAction): diff --git a/activity_browser/actions/database/database_open.py b/activity_browser/actions/database/database_open.py index 14b1b4710..4268c8b59 100644 --- a/activity_browser/actions/database/database_open.py +++ b/activity_browser/actions/database/database_open.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from qtpy.QtCore import Qt, QEventLoop @@ -6,7 +6,7 @@ from activity_browser.ui import widgets from activity_browser.actions.base import ABAction, exception_dialogs -log = getLogger(__name__) + class DatabaseOpen(ABAction): diff --git a/activity_browser/actions/database/database_redo_allocation.py b/activity_browser/actions/database/database_redo_allocation.py index 78c9adbc1..5fbd0c080 100644 --- a/activity_browser/actions/database/database_redo_allocation.py +++ b/activity_browser/actions/database/database_redo_allocation.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from qtpy import QtGui @@ -6,7 +6,7 @@ from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd -log = getLogger(__name__) + class DatabaseRedoAllocation(ABAction): @@ -31,7 +31,7 @@ def run(db_name: str): signals.new_statusbar_message.emit(f"Allocation values for database {db_name} updated.") except KeyError as exc: signals.new_statusbar_message.emit("A property for the allocation calculation was not found!") - log.error(f"A property for the allocation calculation was not found: {exc}") + logger.error(f"A property for the allocation calculation was not found: {exc}") except ZeroDivisionError as exc: signals.new_statusbar_message.emit(str(exc)) - log.error(f"Zero division in allocation calculation: {exc}") + logger.error(f"Zero division in allocation calculation: {exc}") diff --git a/activity_browser/actions/exchange/exchange_modify.py b/activity_browser/actions/exchange/exchange_modify.py index a7b89c753..0ab8d1330 100644 --- a/activity_browser/actions/exchange/exchange_modify.py +++ b/activity_browser/actions/exchange/exchange_modify.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from qtpy.QtWidgets import QMessageBox from bw2data.proxies import ExchangeProxyBase @@ -12,7 +12,7 @@ from ..parameter.parameter_new_automatic import ParameterNewAutomatic from .exchange_formula_remove import ExchangeFormulaRemove -log = getLogger(__name__) + class ExchangeModify(ABAction): diff --git a/activity_browser/actions/metadatastore_open.py b/activity_browser/actions/metadatastore_open.py index 474ea0988..59e75f95c 100644 --- a/activity_browser/actions/metadatastore_open.py +++ b/activity_browser/actions/metadatastore_open.py @@ -1,11 +1,11 @@ -from logging import getLogger +from loguru import logger from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.core.application import global_shortcut -log = getLogger(__name__) + class MetaDataStoreOpen(ABAction): diff --git a/activity_browser/actions/method/importer/method_importer_bw2io.py b/activity_browser/actions/method/importer/method_importer_bw2io.py index 5c13de8b6..b7d40eab4 100644 --- a/activity_browser/actions/method/importer/method_importer_bw2io.py +++ b/activity_browser/actions/method/importer/method_importer_bw2io.py @@ -1,5 +1,5 @@ import os.path -from logging import getLogger +from loguru import logger from qtpy.QtCore import Signal, SignalInstance @@ -11,7 +11,7 @@ from .method_importer_ecoinvent import ExtractExcelThread, MethodImporterEcoinvent -log = getLogger(__name__) + class MethodImporterBW2IO(MethodImporterEcoinvent): diff --git a/activity_browser/actions/method/importer/method_importer_ecoinvent.py b/activity_browser/actions/method/importer/method_importer_ecoinvent.py index c9d3c151e..9d19059d6 100644 --- a/activity_browser/actions/method/importer/method_importer_ecoinvent.py +++ b/activity_browser/actions/method/importer/method_importer_ecoinvent.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtCore from qtpy.QtCore import Signal, SignalInstance @@ -10,7 +10,7 @@ from activity_browser.bwutils.io.ecoinvent_lcia_importer import EcoinventLCIAImporter from activity_browser.ui.core import threading -log = getLogger(__name__) + class MethodImporterEcoinvent(ABAction): diff --git a/activity_browser/actions/method/method_delete.py b/activity_browser/actions/method/method_delete.py index c46a2c08d..934477b17 100644 --- a/activity_browser/actions/method/method_delete.py +++ b/activity_browser/actions/method/method_delete.py @@ -1,6 +1,6 @@ from os import name from typing import List -from logging import getLogger +from loguru import logger from qtpy import QtWidgets @@ -9,7 +9,7 @@ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons -log = getLogger(__name__) + class MethodDelete(ABAction): @@ -59,7 +59,7 @@ def run(methods: List[tuple]): # delete all methods by deregistering them for method in all_methods: method.deregister() - log.info(f"Deleted method {method.name}") + logger.info(f"Deleted method {method.name}") # remove deleted methods from all calculation setups MethodDelete.remove_methods_from_calculation_setups(to_remove) @@ -82,7 +82,7 @@ def remove_methods_from_calculation_setups(method_names: set[tuple]) -> None: ia.remove(name) changed_any = True - log.info( + logger.info( f"Updated calculation setup '{cs_name}': removed impact category {name}" ) @@ -90,4 +90,4 @@ def remove_methods_from_calculation_setups(method_names: set[tuple]) -> None: if changed_any: bd.calculation_setups.serialize() except Exception: - log.exception("Failed to update calculation setups after method rename") + logger.exception("Failed to update calculation setups after method rename") diff --git a/activity_browser/actions/method/method_duplicate.py b/activity_browser/actions/method/method_duplicate.py index f59ee5d51..cef76730b 100644 --- a/activity_browser/actions/method/method_duplicate.py +++ b/activity_browser/actions/method/method_duplicate.py @@ -1,5 +1,5 @@ from typing import List -from logging import getLogger +from loguru import logger from qtpy import QtWidgets @@ -10,7 +10,7 @@ from .method_open import MethodOpen -log = getLogger(__name__) + class MethodDuplicate(ABAction): @@ -57,7 +57,7 @@ def run(methods: List[tuple], level: str = None): if new_name in methods: raise Exception("New method name already in use") method.copy(new_name) - log.info(f"Copied method {method.name} into {new_name}") + logger.info(f"Copied method {method.name} into {new_name}") MethodOpen.run(new_names) diff --git a/activity_browser/actions/method/method_meta_modify.py b/activity_browser/actions/method/method_meta_modify.py index d62d6e35b..1fadfc155 100644 --- a/activity_browser/actions/method/method_meta_modify.py +++ b/activity_browser/actions/method/method_meta_modify.py @@ -1,5 +1,5 @@ from typing import List -from logging import getLogger +from loguru import logger from qtpy import QtWidgets @@ -8,7 +8,7 @@ from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons -log = getLogger(__name__) + class MethodMetaModify(ABAction): @@ -22,7 +22,7 @@ class MethodMetaModify(ABAction): @exception_dialogs def run(method_name: tuple[str], key: str, value: str): if method_name not in bd.methods: - log.warning(f"Can't modify metadata for method {method_name} - method not found") + logger.warning(f"Can't modify metadata for method {method_name} - method not found") return bd.methods[method_name][key] = value diff --git a/activity_browser/actions/method/method_new.py b/activity_browser/actions/method/method_new.py index 962e4944a..7f86a2e1d 100644 --- a/activity_browser/actions/method/method_new.py +++ b/activity_browser/actions/method/method_new.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets @@ -10,7 +10,7 @@ from .method_open import MethodOpen -log = getLogger(__name__) + class MethodNew(ABAction): @@ -67,7 +67,7 @@ def run(): method.register() method.write([]) # Write empty list of characterization factors - log.info(f"Created new impact category: {new_name}") + logger.info(f"Created new impact category: {new_name}") # Open the method in the ImpactCategoryDetails page from activity_browser.layouts import pages diff --git a/activity_browser/actions/method/method_rename.py b/activity_browser/actions/method/method_rename.py index ff6578a43..4156c10ee 100644 --- a/activity_browser/actions/method/method_rename.py +++ b/activity_browser/actions/method/method_rename.py @@ -1,5 +1,5 @@ from typing import List -from logging import getLogger +from loguru import logger from qtpy import QtWidgets @@ -9,7 +9,7 @@ from activity_browser.ui import dialogs from activity_browser.actions.base import ABAction, exception_dialogs -log = getLogger(__name__) + class MethodRename(ABAction): @@ -102,11 +102,11 @@ def rename_method_in_calculation_setups(old_name: tuple, new_name: tuple) -> Non ia[i] = new_name changed_any = True - log.info( + logger.info( f"Updated calculation setup '{cs_name}': renamed impact category {old_name} -> {new_name}" ) if changed_any: bd.calculation_setups.serialize() except Exception: - log.exception("Failed to update calculation setups after method rename") + logger.exception("Failed to update calculation setups after method rename") diff --git a/activity_browser/actions/parameter/parameter_modify.py b/activity_browser/actions/parameter/parameter_modify.py index ea29800eb..8686011c2 100644 --- a/activity_browser/actions/parameter/parameter_modify.py +++ b/activity_browser/actions/parameter/parameter_modify.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger import bw2data as bd from bw2data.parameters import ParameterBase, parameters, ActivityParameter, Group, GroupDependency @@ -10,7 +10,7 @@ from .parameter_rename import ParameterRename -log = getLogger(__name__) + class ParameterModify(ABAction): @@ -51,6 +51,6 @@ def fix_broken_groups(): try: ActivityParameter._static_dependencies(group.name) except DoesNotExist: - log.warning(f"Removing broken parameter group {group.name}") + logger.warning(f"Removing broken parameter group {group.name}") GroupDependency.get(GroupDependency.group == group.name).delete_instance() group.delete_instance() diff --git a/activity_browser/actions/project/project_create_template.py b/activity_browser/actions/project/project_create_template.py index a32dbeb92..9a4b8104b 100644 --- a/activity_browser/actions/project/project_create_template.py +++ b/activity_browser/actions/project/project_create_template.py @@ -1,7 +1,7 @@ import os import json import tarfile -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtCore import platformdirs @@ -11,7 +11,7 @@ from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.core.threading import ABThread -log = getLogger(__name__) + class ProjectCreateTemplate(ABAction): @@ -86,8 +86,8 @@ def run_safely(self): with open(os.path.join(project_dir, ".project-name.json"), "w") as f: json.dump({"name": self.project_name}, f) - log.info("Creating project template - this could take a few minutes...") + logger.info("Creating project template - this could take a few minutes...") with tarfile.open(self.save_path, "w:gz") as tar: tar.add(project_dir, arcname=bd.utils.safe_filename(self.project_name)) - log.info(f"Created template from `{self.project_name}`.") + logger.info(f"Created template from `{self.project_name}`.") diff --git a/activity_browser/actions/project/project_export.py b/activity_browser/actions/project/project_export.py index 96f27663a..3a7e5ac5c 100644 --- a/activity_browser/actions/project/project_export.py +++ b/activity_browser/actions/project/project_export.py @@ -1,7 +1,7 @@ import os import json import tarfile -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtCore @@ -12,7 +12,7 @@ from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.core.threading import ABThread -log = getLogger(__name__) + class ProjectExport(ABAction): @@ -74,8 +74,8 @@ def run_safely(self): with open(os.path.join(project_dir, ".project-name.json"), "w") as f: json.dump({"name": self.project_name}, f) - log.info("Creating project backup archive - this could take a few minutes...") + logger.info("Creating project backup archive - this could take a few minutes...") with tarfile.open(self.save_path, "w:gz") as tar: tar.add(project_dir, arcname=bd.utils.safe_filename(self.project_name)) - log.info(f"Project `{self.project_name}` exported.") + logger.info(f"Project `{self.project_name}` exported.") diff --git a/activity_browser/actions/project/project_import.py b/activity_browser/actions/project/project_import.py index b1b840e50..f70a81a64 100644 --- a/activity_browser/actions/project/project_import.py +++ b/activity_browser/actions/project/project_import.py @@ -1,7 +1,7 @@ import codecs import json import tarfile -from logging import getLogger +from loguru import logger import bw2data as bd from qtpy import QtWidgets, QtCore @@ -13,7 +13,7 @@ from activity_browser.ui.icons import qicons from activity_browser.ui.core.threading import ABThread -log = getLogger(__name__) + class ProjectImport(ABAction): @@ -102,7 +102,7 @@ def get_project_name(fp): class ImportThread(ABThread): def run_safely(self): - log.debug('Starting project import:' + logger.debug('Starting project import:' f'\nPATH: {self.path}' f'\nNAME: {self.project_name}') backup.restore_project_directory(fp=self.path, project_name=self.project_name) @@ -112,5 +112,5 @@ def run_safely(self): ds.full_hash = False ds.save() - log.info(f"Project `{self.project_name}` imported.") + logger.info(f"Project `{self.project_name}` imported.") diff --git a/activity_browser/actions/project/project_local_import.py b/activity_browser/actions/project/project_local_import.py index 20304bb66..3be01a8b2 100644 --- a/activity_browser/actions/project/project_local_import.py +++ b/activity_browser/actions/project/project_local_import.py @@ -1,6 +1,6 @@ import json from tarfile import open as tar_open, TarFile, TarError -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtCore from bw2io import restore_project_directory @@ -9,7 +9,7 @@ from activity_browser.mod import bw2data as bd from activity_browser.ui import icons, widgets -log = getLogger(__name__) + class ProjectLocalImportWindow(QtWidgets.QDialog): @@ -237,7 +237,7 @@ def _import_project(self): original_name = self._selected_project_name() new_name = self._project_name() if original_name and new_name: - log.info(f"Importing project with name {new_name} " + logger.info(f"Importing project with name {new_name} " f"(original name {original_name})") self.import_button.setText("Creating project...") self.import_button.setEnabled(False) @@ -254,7 +254,7 @@ def _import_project(self): self.setCursor(QtCore.Qt.ArrowCursor) self.accept() else: - log.error( + logger.error( f"Project name ({new_name}) or " f"import name ({original_name}) is not valid." ) diff --git a/activity_browser/actions/project/project_migrate25.py b/activity_browser/actions/project/project_migrate25.py index a522c70ea..7367dbb49 100644 --- a/activity_browser/actions/project/project_migrate25.py +++ b/activity_browser/actions/project/project_migrate25.py @@ -1,5 +1,5 @@ from tqdm import tqdm -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtGui, QtCore import bw2data as bd @@ -11,7 +11,7 @@ from activity_browser.ui.icons import qicons from activity_browser.ui.core.threading import ABThread -log = getLogger(__name__) + class ProjectMigrate25(ABAction): @@ -94,7 +94,7 @@ class MigrateThread(ABThread): def run_safely(self): self.pre_process_methods() - log.info("Updating and processing all datasets in the project") + logger.info("Updating and processing all datasets in the project") bd.projects.set_current(bd.projects.current) for db_name in bd.databases: @@ -109,7 +109,7 @@ def run_safely(self): @classmethod def pre_process_methods(cls): - log.info("Pre-processing methods for migration to bw25") + logger.info("Pre-processing methods for migration to bw25") data = {m: bd.Method(m).load() for m in bd.methods} df = pd.DataFrame([(k, v[0][0], v[0][1], v[1]) for k, values in data.items() for v in values @@ -139,7 +139,7 @@ def update_database_activity_types(cls, db_name: str): if not isinstance(database, bd.backends.SQLiteBackend): return - log.info(f"Updating activity types in {db_name}") + logger.info(f"Updating activity types in {db_name}") raw = database.load() for key, ds in tqdm(raw.items(), desc=f"Updating activity types in {db_name}", unit="activity", total=len(raw)): diff --git a/activity_browser/actions/project/project_new_template.py b/activity_browser/actions/project/project_new_template.py index b6dd52ea1..198579d90 100644 --- a/activity_browser/actions/project/project_new_template.py +++ b/activity_browser/actions/project/project_new_template.py @@ -1,5 +1,5 @@ from qtpy import QtWidgets, QtCore -from logging import getLogger +from loguru import logger import bw2data as bd from bw2io import backup @@ -10,7 +10,7 @@ from activity_browser.ui.core.threading import ABThread from activity_browser.ui.icons import qicons -log = getLogger(__name__) + class ProjectNewFromTemplate(ABAction): @@ -77,10 +77,10 @@ class ImportThread(ABThread): project_name: str def run_safely(self): - log.debug('Creating project from template:' + logger.debug('Creating project from template:' f'\nPATH: {self.path}' f'\nNAME: {self.project_name}') backup.restore_project_directory(fp=self.path, project_name=self.project_name) - log.info(f"Project `{self.project_name}` created.") + logger.info(f"Project `{self.project_name}` created.") diff --git a/activity_browser/actions/project/project_remote_import.py b/activity_browser/actions/project/project_remote_import.py index 5397e4284..7cefe002f 100644 --- a/activity_browser/actions/project/project_remote_import.py +++ b/activity_browser/actions/project/project_remote_import.py @@ -1,6 +1,6 @@ from typing import Any from urllib.parse import urljoin -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtCore @@ -11,7 +11,7 @@ from activity_browser.mod import bw2data as bd from activity_browser.ui import icons, widgets -log = getLogger(__name__) + class CatalogueModel(QtCore.QAbstractTableModel): @@ -278,7 +278,7 @@ def _import_project(self): original_name = self._selected_project_name() new_name = self._project_name() if original_name and new_name: - log.info(f"Importing project with name {new_name} " + logger.info(f"Importing project with name {new_name} " f"(original name {original_name})") self.import_button.setText("Creating project...") self.import_button.setEnabled(False) @@ -296,7 +296,7 @@ def _import_project(self): self.setCursor(QtCore.Qt.ArrowCursor) self.accept() else: - log.error(f"Project name ({new_name}) or import name ({original_name}) is not valid.") + logger.error(f"Project name ({new_name}) or import name ({original_name}) is not valid.") diff --git a/activity_browser/actions/project/project_switch.py b/activity_browser/actions/project/project_switch.py index 944d0b433..738179ccb 100644 --- a/activity_browser/actions/project/project_switch.py +++ b/activity_browser/actions/project/project_switch.py @@ -1,5 +1,5 @@ import datetime -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtCore @@ -10,7 +10,7 @@ from .project_migrate25 import ProjectMigrate25 -log = getLogger(__name__) + class ProjectSwitch(ABAction): @@ -40,7 +40,7 @@ class ProjectSwitch(ABAction): def run(project_name: str): # compare the new to the current project name and switch to the new one if the two are not the same if project_name == bd.projects.current: - log.debug(f"Brightway2 already selected: {project_name}") + logger.debug(f"Brightway2 already selected: {project_name}") return dialog = ProjectChangeDialog(project_name, application.main_window) @@ -53,10 +53,10 @@ def run(project_name: str): dialog.close() if not bd.projects.twofive: - log.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") + logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") ProjectSwitch.set_warning_bar() - log.info(f"Brightway2 current project: {project_name}") + logger.info(f"Brightway2 current project: {project_name}") # update the last opened timestamp bd.projects.dataset.data["last_opened"] = datetime.datetime.now().isoformat() diff --git a/activity_browser/bwutils/commontasks.py b/activity_browser/bwutils/commontasks.py index 8b616f899..2d50df02f 100644 --- a/activity_browser/bwutils/commontasks.py +++ b/activity_browser/bwutils/commontasks.py @@ -1,7 +1,7 @@ import hashlib import textwrap from datetime import datetime -from logging import getLogger +from loguru import logger from collections import OrderedDict import arrow @@ -17,7 +17,7 @@ from .metadata import AB_metadata from .utils import Parameter -log = getLogger(__name__) + """ bwutils is a collection of methods that build upon brightway2 and are generic enough to provide here so that we avoid @@ -105,7 +105,7 @@ def cleanup_deleted_bw_projects() -> None: NOTE: This cannot be done from within the AB. """ n_dir = bd.projects.purge_deleted_directories() - log.info(f"Deleted {n_dir} unused project directories!") + logger.info(f"Deleted {n_dir} unused project directories!") def projects_by_last_opened(): @@ -452,7 +452,7 @@ def get_exchanges_in_scenario_difference_file_notation(exchanges): except: # The input activity does not exist. remove the exchange. - log.error( + logger.error( "Something did not work with the following exchange: {}. It was removed from the list.".format( exc ) diff --git a/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py b/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py index 181a61eab..a59b3d93e 100644 --- a/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py +++ b/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py @@ -1,6 +1,6 @@ import os from zipfile import ZipFile -from logging import getLogger +from loguru import logger from bw2io.importers import Ecospold2BiosphereImporter from bw2io.importers.ecospold2_biosphere import EMISSIONS_CATEGORIES @@ -11,7 +11,7 @@ from ...info import __ei_versions__ from ...utils import sort_semantic_versions -log = getLogger(__name__) + def create_default_biosphere3(version) -> None: @@ -20,11 +20,11 @@ def create_default_biosphere3(version) -> None: version = version[:3] if version == sort_semantic_versions(__ei_versions__)[0][:3]: - log.debug(f"Installing biosphere version >{version}<") + logger.debug(f"Installing biosphere version >{version}<") # most recent version eb = Ecospold2BiosphereImporter() else: - log.debug(f"Installing legacy biosphere version >{version}<") + logger.debug(f"Installing legacy biosphere version >{version}<") # not most recent version, import legacy biosphere from AB eb = ABEcospold2BiosphereImporter(version=version) eb.apply_strategies() @@ -74,7 +74,7 @@ def extract_flow_data(o): ) as file: root = objectify.parse(file).getroot() - log.debug(f"Installing biosphere {use_version} for chosen version {version}") + logger.debug(f"Installing biosphere {use_version} for chosen version {version}") flow_data = bd.utils.recursive_str_to_unicode( [extract_flow_data(ds) for ds in root.iterchildren()] ) diff --git a/activity_browser/bwutils/io/ecoinvent_importer.py b/activity_browser/bwutils/io/ecoinvent_importer.py index b584f3c23..909843617 100644 --- a/activity_browser/bwutils/io/ecoinvent_importer.py +++ b/activity_browser/bwutils/io/ecoinvent_importer.py @@ -5,7 +5,7 @@ from io import BytesIO from lxml import objectify from functools import partial -from logging import getLogger +from loguru import logger import tqdm import bw2data as bd @@ -35,7 +35,7 @@ update_social_flows_in_older_consequential, ) -log = getLogger(__name__) + class Ecoinvent7zImporter: @@ -72,7 +72,7 @@ def install_ecoinvent(self, db_name, biosphere_name: str = "biosphere3"): """ # if the db already exists, warn the user of the impending overwriting and delete the existing database if db_name in bd.databases: - log.warning(f"Database already exists, overwriting {db_name}") + logger.warning(f"Database already exists, overwriting {db_name}") bd.Database(db_name).delete(warn=False) if self.is_compressed: @@ -123,7 +123,7 @@ def apply_strategies(self, db_data, biosphere_name): return db_data def read_archive_to_bytes(self) -> {str: BytesIO}: - log.info("Extracting .7z archive to memory") + logger.info("Extracting .7z archive to memory") with py7zr.SevenZipFile(self.archive_path, mode='r') as archive: # Find all .spold dataset files file_list = [ @@ -138,7 +138,7 @@ def read_archive_to_bytes(self) -> {str: BytesIO}: def process_bytes(self, spold_bytes: {str: BytesIO}, db_name: str) -> list: with multiprocessing.Pool(processes=multiprocessing.cpu_count()) as pool: - log.info(f"Extracting XML data from {len(spold_bytes)} datasets") + logger.info(f"Extracting XML data from {len(spold_bytes)} datasets") results = [ pool.apply_async( self.extract_activity, diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index c77ef3118..0261cb929 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -2,7 +2,7 @@ import sqlite3 import sys import pickle -from logging import getLogger +from loguru import logger from typing import Literal import pandas as pd @@ -17,7 +17,7 @@ from .metadata import MetaDataStore from .fields import secondary_types, primary, secondary -log = getLogger(__name__) + class MDSLoader(QtCore.QObject): @@ -58,7 +58,7 @@ def primary_load_project(self): primary_df["key"] = list(zip(primary_df["database"], primary_df["code"])) primary_df.index = pd.MultiIndex.from_tuples(primary_df["key"], names=["database", "code"]) - log.debug(f"Primary metadata loaded with {len(primary_df)} rows") + logger.debug(f"Primary metadata loaded with {len(primary_df)} rows") self.mds.dataframe = primary_df for idx in primary_df.index: @@ -71,7 +71,7 @@ def secondary_load_project(self, secondary_df: pd.DataFrame, sqlite_db: str): return assert all(secondary_df.index.isin(self.mds.dataframe.index)) - log.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") + logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") self.mds.dataframe = pd.concat([self.mds.dataframe[primary], secondary_df], axis=1) for idx in secondary_df.index: @@ -96,7 +96,7 @@ def primary_load_database(self, database_name: str): primary_df["key"] = list(zip(primary_df["database"], primary_df["code"])) primary_df.index = pd.MultiIndex.from_tuples(primary_df["key"], names=["database", "code"]) - log.debug(f"Primary metadata loaded with {len(primary_df)} rows") + logger.debug(f"Primary metadata loaded with {len(primary_df)} rows") self.mds.dataframe = pd.concat([self.mds.dataframe, primary_df]) for idx in primary_df.index: @@ -110,10 +110,10 @@ def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): indices = self.mds.dataframe.loc[[database]].index if not all(secondary_df.index.isin(indices)): - log.debug("Secondary database metadata dropping rows") + logger.debug("Secondary database metadata dropping rows") secondary_df = secondary_df[secondary_df.index.isin(indices)] - log.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") + logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") self._fix_categories(secondary_df) self.mds.dataframe.update(secondary_df) @@ -143,7 +143,7 @@ def run_safely(self, databases: list[str], sqlite_db: str): for proc in processes: stdout_data, stderr_data = proc.communicate() if proc.returncode != 0: - log.error(f"Error loading metadata: {stderr_data.decode()}") + logger.error(f"Error loading metadata: {stderr_data.decode()}") continue df = pickle.loads(stdout_data) if df.empty: diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index ffab3ad23..6c691f4d1 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -1,5 +1,5 @@ from time import time -from logging import getLogger +from loguru import logger from typing import Literal import pandas as pd @@ -9,7 +9,7 @@ from .fields import all, all_types -log = getLogger(__name__) + class MetaDataStore(QObject): @@ -83,7 +83,7 @@ def flush_mutations(self): self._added.clear(), self._updated.clear(), self._deleted.clear() - log.debug(f"Metadatastore sync signal completed in {time() - t:.2f} seconds") + logger.debug(f"Metadatastore sync signal completed in {time() - t:.2f} seconds") def match(self, **kwargs: dict[str, str]) -> pd.DataFrame: """Return a slice of the dataframe matching the criteria. diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index 2c969c52f..4070f3000 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger import pandas as pd import numpy as np @@ -11,7 +11,7 @@ from .metadata import MetaDataStore from .fields import primary, secondary, all_types -log = getLogger(__name__) + class MDSUpdater(QtCore.QObject): diff --git a/activity_browser/bwutils/montecarlo.py b/activity_browser/bwutils/montecarlo.py index 09b4e91df..7e3c48f08 100644 --- a/activity_browser/bwutils/montecarlo.py +++ b/activity_browser/bwutils/montecarlo.py @@ -1,7 +1,7 @@ from collections import defaultdict from time import time from typing import Optional, Union -from logging import getLogger +from loguru import logger import bw2calc as bc import bw2data as bd @@ -13,7 +13,7 @@ from .manager import MonteCarloParameterManager -log = getLogger(__name__) + class MonteCarloLCA(object): @@ -87,8 +87,8 @@ def construct_lca( characterization: bool = True, seed_override: Optional[int] = None, ) -> bc.MultiLCA: - log.info(f"Monte Carlo demands: {demands}") - log.info(f"Monte Carlo impact categories: {method_config}") + logger.info(f"Monte Carlo demands: {demands}") + logger.info(f"Monte Carlo impact categories: {method_config}") demands = { index: {bd.get_activity(k).id: v for k, v in fu.items()} for index, fu in demands.items() @@ -307,7 +307,7 @@ def calculate(self, iterations: int = 10, seed: Optional[int] = None, **kwargs): # self.lca.lcia_calculation() self.results[iteration, int(row), col] = self.lca.scores[(m, row)] - log.info( + logger.info( f"Monte Carlo LCA: finished {iterations} iterations for {len(self.func_units)} reference flows and " f"{len(self.methods)} methods in {np.round(time() - start, 2)} seconds." ) @@ -331,10 +331,10 @@ def get_results_by(self, act_key=None, method=None): if act_key: act_index = self.activity_index.get(act_key) - log.info(f"Activity key provided: {act_key} {act_index}") + logger.info(f"Activity key provided: {act_key} {act_index}") if method: method_index = self.method_index.get(method) - log.info(f"Method provided: {method} {method_index}") + logger.info(f"Method provided: {method} {method_index}") if not act_key and not method: return self.results @@ -393,7 +393,7 @@ def get_labels( def perform_MonteCarlo_LCA(project="default", cs_name=None, iterations=10): """Performs Monte Carlo LCA based on a calculation setup and returns the Monte Carlo LCA object.""" - log.info(f"-- Monte Carlo LCA --\n Project: {project} CS: {cs_name}") + logger.info(f"-- Monte Carlo LCA --\n Project: {project} CS: {cs_name}") bd.projects.set_current(project, update=False) # perform Monte Carlo simulation diff --git a/activity_browser/bwutils/multilca.py b/activity_browser/bwutils/multilca.py index 9d709443b..0a46e91f2 100644 --- a/activity_browser/bwutils/multilca.py +++ b/activity_browser/bwutils/multilca.py @@ -1,7 +1,7 @@ from collections import OrderedDict from copy import deepcopy from typing import Iterable, Optional, Union -from logging import getLogger +from loguru import logger import bw2calc as bc import numpy as np @@ -15,7 +15,7 @@ from .errors import ReferenceFlowValueError from .metadata import AB_metadata -log = getLogger(__name__) + ca = ABContributionAnalysis() @@ -565,7 +565,7 @@ def join_df_with_metadata( complete_index = special_keys + keys joined = joined.reindex(complete_index, axis="index", fill_value=0.0) except: - log.error( + logger.error( "Could not put 'Total', 'Rest (+)' and 'Rest (-)' on positions 0, 1 and 2 in the dataframe." ) joined.index = cls.get_labels(joined.index, fields=x_fields) diff --git a/activity_browser/bwutils/sensitivity_analysis.py b/activity_browser/bwutils/sensitivity_analysis.py index 1a12f8cc0..4dec78461 100644 --- a/activity_browser/bwutils/sensitivity_analysis.py +++ b/activity_browser/bwutils/sensitivity_analysis.py @@ -8,7 +8,7 @@ import os import traceback from time import time -from logging import getLogger +from loguru import logger import bw2calc as bc import numpy as np @@ -28,7 +28,7 @@ from bw2calc import GraphTraversal -log = getLogger(__name__) + def get_lca(fu, method): @@ -36,7 +36,7 @@ def get_lca(fu, method): lca = bc.LCA(fu, method=method) lca.lci() lca.lcia() - log.info(f"Non-stochastic LCA score: {lca.score}") + logger.info(f"Non-stochastic LCA score: {lca.score}") # add reverse dictionaries lca.activity_dict_rev, lca.product_dict_rev, lca.biosphere_dict_rev = ( @@ -57,7 +57,7 @@ def filter_technosphere_exchanges(lca, cutoff=0.05, max_calc=1000): for e in res["edges"]: if e.consumer_index != -1: # filter out head introduced in graph traversal technosphere_exchange_indices.append((e.producer_index, e.consumer_index)) - log.info( + logger.info( "TECHNOSPHERE {} filtering resulted in {} of {} exchanges and took {} iterations in {} seconds.".format( lca.technosphere_matrix.shape, len(technosphere_exchange_indices), @@ -78,7 +78,7 @@ def filter_biosphere_exchanges(lca, cutoff=0.005): finv = inv.multiply(abs(inv) > abs(lca.score / (1 / cutoff))) biosphere_exchange_indices = list(zip(*finv.nonzero())) explained_fraction = finv.sum() / lca.score - log.info( + logger.info( "BIOSPHERE {} filtering resulted in {} of {} exchanges ({}% of total impact) and took {} seconds.".format( inv.shape, finv.nnz, @@ -140,7 +140,7 @@ def drop_no_uncertainty_exchanges(excs, indices): if exc.get("uncertainty type") and exc.get("uncertainty type") >= 1: excs_no.append(exc) indices_no.append(ind) - log.info( + logger.info( "Dropping {} exchanges of {} with no uncertainty. {} remaining.".format( len(excs) - len(excs_no), len(excs), len(excs_no) ) @@ -214,7 +214,7 @@ def get_CF_dataframe(lca, only_uncertain_CFs=True): "CF: " + bio_act["name"] + str(bio_act["categories"]) ) - log.info( + logger.info( "CHARACTERIZATION FACTORS filtering resulted in including {} of {} characteriation factors.".format( len(data), len(lca.cf_params), @@ -230,10 +230,10 @@ def get_parameters_DF(mc): if bool(mc.parameter_data): # returns False if dict is empty dfp = pd.DataFrame(mc.parameter_data).T dfp["GSA name"] = "P: " + dfp["name"] - log.info(f"PARAMETERS: {len(dfp)}") + logger.info(f"PARAMETERS: {len(dfp)}") return dfp else: - log.info("PARAMETERS: None included.") + logger.info("PARAMETERS: None included.") return pd.DataFrame() # return emtpy df @@ -330,10 +330,10 @@ def perform_GSA( except Exception as e: traceback.print_exc() # todo: QMessageBox.warning(self, 'Could not perform Delta analysis', str(e)) - log.error("Initializing the GSA failed.") + logger.error("Initializing the GSA failed.") return None - log.info( + logger.info( f"-- GSA --\n Project: {bd.projects.current} CS: {self.mc.cs_name} " f"Activity: {self.activity} Method: {self.method}", ) @@ -421,12 +421,12 @@ def perform_GSA( # self.Y = np.log(np.abs(self.Y)) # this makes it more robust for very uneven distributions of LCA results if np.all(self.Y > 0): # all positive numbers self.Y = np.log(np.abs(self.Y)) - log.info("All positive LCA scores. Log-transformation performed.") + logger.info("All positive LCA scores. Log-transformation performed.") elif np.all(self.Y < 0): # all negative numbers self.Y = -np.log(np.abs(self.Y)) - log.info("All negative LCA scores. Log-transformation performed.") + logger.info("All negative LCA scores. Log-transformation performed.") else: # mixed positive and negative numbers - log.warning( + logger.warning( "Log-transformation cannot be applied as LCA scores overlap zero." ) @@ -440,7 +440,7 @@ def perform_GSA( # perform delta analysis time_delta = time() self.Si = delta.analyze(self.problem, self.X, self.Y, print_to_console=False) - log.info( + logger.info( "Delta analysis took {} seconds".format( np.round(time() - time_delta, 2), ) @@ -457,7 +457,7 @@ def perform_GSA( self.df_final.reset_index(inplace=True) self.df_final["pedigree"] = [str(x) for x in self.df_final["pedigree"]] - log.info("GSA took {} seconds".format(np.round(time() - start, 2))) + logger.info("GSA took {} seconds".format(np.round(time() - start, 2))) def get_save_name(self): save_name = ( diff --git a/activity_browser/bwutils/strategies.py b/activity_browser/bwutils/strategies.py index 183402a2b..21b7d03de 100644 --- a/activity_browser/bwutils/strategies.py +++ b/activity_browser/bwutils/strategies.py @@ -2,7 +2,7 @@ import hashlib import json from typing import Collection -from logging import getLogger +from loguru import logger from bw2io.errors import StrategyError from bw2io.strategies.generic import (format_nonunique_key_error, @@ -15,7 +15,7 @@ from ..bwutils.errors import ExchangeErrorValues from .commontasks import clean_activity_name -log = getLogger(__name__) + TECHNOSPHERE_TYPES = {"technosphere", "substitution", "production"} BIOSPHERE_TYPES = {"economic", "emission", "natural resource", "social"} @@ -152,7 +152,7 @@ def relink_exchanges(exchanges: list, candidates: dict, duplicates: dict) -> tup # Commit changes every 10k exchanges. transaction.commit() except (StrategyError, bd.errors.ValidityError) as e: - log.error(e) + logger.error(e) transaction.rollback() return (remainder, altered, unlinked_exchanges) @@ -165,7 +165,7 @@ def relink_exchanges_existing_db( This means possibly doing a lot of sqlite update calls. """ if old == other.name: - log.info("No point relinking to same database.") + logger.info("No point relinking to same database.") return assert db.backend == "sqlite", "Relinking only allowed for SQLITE backends" assert other.backend == "sqlite", "Relinking only allowed for SQLITE backends" @@ -195,7 +195,7 @@ def relink_exchanges_existing_db( exchanges, candidates, duplicates ) db.process() - log.info( + logger.info( "Relinked database '{}', {} exchange inputs changed from '{}' to '{}'.".format( db.name, altered, old, other.name ) @@ -205,7 +205,7 @@ def relink_exchanges_existing_db( def relink_activity_exchanges(act, old: str, other: bd.Database) -> tuple: if old == other.name: - log.info("No point relinking to same database.") + logger.info("No point relinking to same database.") return db = bd.Database(act.key[0]) assert db.backend == "sqlite", "Relinking only allowed for SQLITE backends" @@ -232,7 +232,7 @@ def relink_activity_exchanges(act, old: str, other: bd.Database) -> tuple: exchanges, candidates, duplicates ) db.process() - log.info( + logger.info( "Relinked database '{}', {} exchange inputs changed from '{}' to '{}'.".format( db.name, altered, old, other.name ) diff --git a/activity_browser/bwutils/superstructure/excel.py b/activity_browser/bwutils/superstructure/excel.py index d2cbb415e..2701b7c61 100644 --- a/activity_browser/bwutils/superstructure/excel.py +++ b/activity_browser/bwutils/superstructure/excel.py @@ -2,14 +2,14 @@ from ast import literal_eval from pathlib import Path from typing import List, Union -from logging import getLogger +from loguru import logger import openpyxl import pandas as pd from .utils import SUPERSTRUCTURE -log = getLogger(__name__) + def convert_tuple_str(x): @@ -24,7 +24,7 @@ def get_sheet_names(document_path: Union[str, Path]) -> List[str]: wb = openpyxl.load_workbook(filename=document_path, read_only=True) return wb.sheetnames except UnicodeDecodeError as e: - log.error("Given document uses an unknown encoding: {}".format(e)) + logger.error("Given document uses an unknown encoding: {}".format(e)) def get_header_index(document_path: Union[str, Path], import_sheet: int): @@ -45,7 +45,7 @@ def get_header_index(document_path: Union[str, Path], import_sheet: int): e.__traceback__ ) except UnicodeDecodeError as e: - log.error("Given document uses an unknown encoding: {}".format(e)) + logger.error("Given document uses an unknown encoding: {}".format(e)) wb.close() raise ValueError("Could not find required headers in given document sheet.") diff --git a/activity_browser/bwutils/superstructure/file_imports.py b/activity_browser/bwutils/superstructure/file_imports.py index ea4ec3fae..5185cf7cc 100644 --- a/activity_browser/bwutils/superstructure/file_imports.py +++ b/activity_browser/bwutils/superstructure/file_imports.py @@ -2,13 +2,13 @@ from abc import ABC, abstractmethod from pathlib import Path from typing import Optional, Union -from logging import getLogger +from loguru import logger import pandas as pd from ..errors import * -log = getLogger(__name__) + class ABFileImporter(ABC): @@ -75,7 +75,7 @@ def database_and_key_check(data: pd.DataFrame) -> None: ) raise IncompatibleDatabaseNamingError() except IncompatibleDatabaseNamingError as e: - log.error(msg) + logger.error(msg) raise e @staticmethod @@ -103,7 +103,7 @@ def production_process_check(data: pd.DataFrame, scenario_names: list) -> None: ) raise ActivityProductionValueError() except ActivityProductionValueError as e: - log.error(msg) + logger.error(msg) raise e @staticmethod @@ -126,7 +126,7 @@ def na_value_check(data: pd.DataFrame, fields: list) -> None: ) raise InvalidSDFEntryValue() except InvalidSDFEntryValue as e: - log.error(msg) + logger.error(msg) raise e @staticmethod diff --git a/activity_browser/bwutils/superstructure/manager.py b/activity_browser/bwutils/superstructure/manager.py index 83167c589..fa007fb49 100644 --- a/activity_browser/bwutils/superstructure/manager.py +++ b/activity_browser/bwutils/superstructure/manager.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import itertools from typing import List, Optional, Union -from logging import getLogger +from loguru import logger import numpy as np import pandas as pd @@ -21,7 +21,7 @@ from .file_dialogs import ABPopup from .utils import SUPERSTRUCTURE, _time_it_, guess_flow_type -log = getLogger(__name__) + EXCHANGE_KEYS = pd.Index(["from key", "to key"]) INDEX_KEYS = pd.Index(["from key", "to key", "flow type"]) @@ -119,7 +119,7 @@ def _combine_columns_intersect(self) -> pd.Index: absent.update(cols.symmetric_difference(scenario_columns(df))) cols = cols.intersection(scenario_columns(df)) for name in absent: - log.warning( + logger.warning( "The following scenario is not found in all provided files and is being dropped: {}".format( name ) @@ -346,7 +346,7 @@ def remove_duplicates(df: pd.DataFrame) -> pd.DataFrame: """ duplicates = df.index.duplicated(keep="last") if duplicates.any(): - log.warning( + logger.warning( "Found and dropped {} duplicate exchanges.".format(duplicates.sum()) ) return df.loc[~duplicates, :] @@ -362,7 +362,7 @@ def build_index(df: pd.DataFrame) -> pd.MultiIndex: """ unknown_flows = df.loc[:, "flow type"].isna() if unknown_flows.any(): - log.warning( + logger.warning( "Not all flow types are known, guessing {} flows".format( unknown_flows.sum() ) @@ -499,7 +499,7 @@ def check_scenario_exchange_values(df: pd.DataFrame, cols: pd.Index): critical.exec_() raise ScenarioExchangeDataNotFoundError elif nas.any(axis=0).any(): - log.warning( + logger.warning( "Replacing empty values from the last loaded scenario difference file" ) if not is_numeric_dtype(np.array(_df.loc[:, cols])): diff --git a/activity_browser/bwutils/superstructure/utils.py b/activity_browser/bwutils/superstructure/utils.py index 76a3e00b1..f5a7c5cc8 100644 --- a/activity_browser/bwutils/superstructure/utils.py +++ b/activity_browser/bwutils/superstructure/utils.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- import time -from logging import getLogger +from loguru import logger import pandas as pd from activity_browser.mod import bw2data as bd -log = getLogger(__name__) + # Different kinds of indexes, to allow for quick selection of data from # the Superstructure DataFrame. @@ -79,7 +79,7 @@ def _time_it_(func): def wrapper(*args): now = time.time() result = func(*args) - log.info(f"{func} -- {time.time() - now}") + logger.info(f"{func} -- {time.time() - now}") return result return wrapper diff --git a/activity_browser/info.py b/activity_browser/info.py index 03af4642c..159d10fc9 100644 --- a/activity_browser/info.py +++ b/activity_browser/info.py @@ -1,11 +1,11 @@ import ast import os.path from importlib.metadata import PackageNotFoundError, version -from logging import getLogger +from loguru import logger from .utils import safe_link_fetch, sort_semantic_versions -log = getLogger(__name__) + # get AB version try: @@ -30,7 +30,7 @@ def get_compatible_versions() -> list: file = page.text else: # silently try a local fallback: - log.debug( + logger.debug( f"Reading online compatible ecoinvent versions failed " f"-attempting local fallback- with this error: {error}" ) @@ -54,13 +54,13 @@ def get_compatible_versions() -> list: else: ei_versions = all_versions[sorted_versions[-1]] - log.debug( + logger.debug( f"Following versions of ecoinvent are compatible with AB {__version__}: {ei_versions}" ) return ei_versions except Exception as error: - log.debug(f"Reading local fallback failed with: {error}") + logger.debug(f"Reading local fallback failed with: {error}") return ["3.4", "3.5", "3.6", "3.7", "3.7.1", "3.8", "3.9", "3.9.1"] diff --git a/activity_browser/layouts/main_window.py b/activity_browser/layouts/main_window.py index 3fa89ad1c..6f223fb24 100644 --- a/activity_browser/layouts/main_window.py +++ b/activity_browser/layouts/main_window.py @@ -1,5 +1,5 @@ import pickle -from logging import getLogger +from loguru import logger from qtpy import QtCore, QtWidgets, QtGui @@ -10,7 +10,7 @@ from activity_browser.layouts.menu_bar import MenuBar -log = getLogger(__name__) + class MainWindow(QtWidgets.QMainWindow): diff --git a/activity_browser/layouts/pages/activity_details/activity_details.py b/activity_browser/layouts/pages/activity_details/activity_details.py index 57412c1ab..62546db1f 100644 --- a/activity_browser/layouts/pages/activity_details/activity_details.py +++ b/activity_browser/layouts/pages/activity_details/activity_details.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from qtpy import QtCore, QtWidgets @@ -15,7 +15,7 @@ from .data_tab import DataTab from .consumers_tab import ConsumersTab -log = getLogger(__name__) + class ActivityDetailsPage(QtWidgets.QWidget): diff --git a/activity_browser/layouts/pages/activity_details/exchanges_tab.py b/activity_browser/layouts/pages/activity_details/exchanges_tab.py index 41bbe6339..da2e061b4 100644 --- a/activity_browser/layouts/pages/activity_details/exchanges_tab.py +++ b/activity_browser/layouts/pages/activity_details/exchanges_tab.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets, QtGui from qtpy.QtCore import Qt @@ -12,7 +12,7 @@ from activity_browser.bwutils import refresh_node, AB_metadata, database_is_locked, database_is_legacy from activity_browser.ui import widgets, icons, delegates -log = getLogger(__name__) + EXCHANGE_MAP = { "natural resource": "biosphere", "emission": "biosphere", "inventory indicator": "biosphere", @@ -216,7 +216,7 @@ def dropEvent(self, event): Args: event: The drop event. """ - log.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") + logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") # Reset the palette on drop self.overlay.deleteLater() @@ -677,7 +677,7 @@ def setData(self, col: int, key: str, value) -> bool: product = self.exchange.input if not isinstance(process, bf.Process) or not isinstance(product, bf.Product): - log.warning(f"Expected a Process and Product, got {type(process)} and {type(product)} instead.") + logger.warning(f"Expected a Process and Product, got {type(process)} and {type(product)} instead.") return False prop_key = key[9:] diff --git a/activity_browser/layouts/pages/activity_details/graph_tab.py b/activity_browser/layouts/pages/activity_details/graph_tab.py index b25759b80..08773f649 100644 --- a/activity_browser/layouts/pages/activity_details/graph_tab.py +++ b/activity_browser/layouts/pages/activity_details/graph_tab.py @@ -1,6 +1,6 @@ import json import os -from logging import getLogger +from loguru import logger from qtpy import QtWebChannel, QtWebEngineWidgets, QtWidgets from qtpy.QtCore import QObject, Qt, QUrl, Signal, SignalInstance, Slot @@ -12,7 +12,7 @@ from activity_browser.ui import widgets from .exchanges_tab import get_exchange_type -log = getLogger(__name__) + class GraphTab(QtWidgets.QWidget): @@ -185,7 +185,7 @@ def get_processor_from_exchange(exchange): source = exchange.input processors = list(source.upstream(kinds=["production"])) if len(processors) > 1: - log.warning("Multiple processors, only taking first one") + logger.warning("Multiple processors, only taking first one") processor = processors[0] return processor.output @@ -230,7 +230,7 @@ def dropEvent(self, event): Args: event: The drop event. """ - log.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") + logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") # Reset the palette on drop self.overlay.deleteLater() @@ -283,10 +283,10 @@ def javaScriptConsoleMessage(self, level: QtWebEngineWidgets.QWebEnginePage.Java _ (str): Unused parameter. """ if level == QtWebEngineWidgets.QWebEnginePage.InfoMessageLevel: - log.info(f"JS Info (Line {line}): {message}") + logger.info(f"JS Info (Line {line}): {message}") elif level == QtWebEngineWidgets.QWebEnginePage.WarningMessageLevel: - log.warning(f"JS Warning (Line {line}): {message}") + logger.warning(f"JS Warning (Line {line}): {message}") elif level == QtWebEngineWidgets.QWebEnginePage.ErrorMessageLevel: - log.error(f"JS Error (Line {line}): {message}") + logger.error(f"JS Error (Line {line}): {message}") else: - log.debug(f"JS Log (Line {line}): {message}") + logger.debug(f"JS Log (Line {line}): {message}") diff --git a/activity_browser/layouts/pages/calculation_setup/scenario_section.py b/activity_browser/layouts/pages/calculation_setup/scenario_section.py index 9b47ce1f9..2fa3167fb 100644 --- a/activity_browser/layouts/pages/calculation_setup/scenario_section.py +++ b/activity_browser/layouts/pages/calculation_setup/scenario_section.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from pathlib import Path from qtpy import QtWidgets @@ -12,7 +12,7 @@ from activity_browser.ui import icons, widgets from activity_browser.bwutils import errors -log = getLogger(__name__) + class ScenarioSection(QtWidgets.QWidget): @@ -303,9 +303,9 @@ def load_action(self) -> None: idx = dialog.import_sheet.currentIndex() file_type_suffix = dialog.path.suffix separator = dialog.field_separator.currentData() - log.debug("separator == '{}'".format(separator)) + logger.debug("separator == '{}'".format(separator)) QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - log.info("Loading Scenario file. This may take a while for large files") + logger.info("Loading Scenario file. This may take a while for large files") # Try and read as a superstructure file # Choose a different routine for reading the file dependent on file type if file_type_suffix == ".feather": @@ -323,7 +323,7 @@ def load_action(self) -> None: # Read the file as a parameter scenario file if it is correspondingly arranged elif len(df.columns.intersection({"Name", "Group"})) == 2: # Try and read as parameter scenario file. - log.info("Superstructure: Attempting to read as parameter scenario file.") + logger.info("Superstructure: Attempting to read as parameter scenario file.") if not df["Group"].dtype == object: df["Group"] = df["Group"].astype(str) @@ -398,7 +398,7 @@ def scenario_db_check(self, df: pd.DataFrame) -> pd.DataFrame: @property def dataframe(self) -> pd.DataFrame: if self.scenario_df.empty: - log.debug("No data in scenario table {}, skipping".format(self.index + 1)) + logger.debug("No data in scenario table {}, skipping".format(self.index + 1)) return self.scenario_df diff --git a/activity_browser/layouts/pages/lca_results/LCA_results.py b/activity_browser/layouts/pages/lca_results/LCA_results.py index 0a0916212..d00dea420 100644 --- a/activity_browser/layouts/pages/lca_results/LCA_results.py +++ b/activity_browser/layouts/pages/lca_results/LCA_results.py @@ -1,7 +1,7 @@ from collections import namedtuple from copy import deepcopy from typing import List, Optional -from logging import getLogger +from loguru import logger from datetime import datetime import numpy as np @@ -21,7 +21,7 @@ ca = ABContributionAnalysis() -log = getLogger(__name__) + def get_header_layout(header_text: str) -> QtWidgets.QVBoxLayout: @@ -167,17 +167,17 @@ def update_scenario_data(self, index: int) -> None: def generate_content_on_click(self, index): if index == self.indexOf(self.tabs.sankey): if not self.tabs.sankey.has_sankey: - log.info("Generating Sankey Tab") + 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: - # log.info("Generating First Tier results") + # logger.info("Generating First Tier results") # self.tabs.ft.has_been_opened = True # self.tabs.ft.update_tab() if index == self.indexOf(self.tabs.tree): if not self.tabs.tree.has_rendered_once: - log.info("Generating Tree Tab") + logger.info("Generating Tree Tab") self.tabs.tree.new_tree() @QtCore.Slot(name="lciaScenarioExport") @@ -1860,11 +1860,11 @@ def calculate_mc_lca(self): iterations = int(self.iterations.text()) seed = None if self.seed.text(): - log.info(f"SEED: {self.seed.text()}") + logger.info(f"SEED: {self.seed.text()}") try: seed = int(self.seed.text()) except ValueError as e: - log.error( + logger.error( "Seed value must be an integer number or left empty.", exc_info=e ) QtWidgets.QMessageBox.warning( @@ -1890,7 +1890,7 @@ def calculate_mc_lca(self): InvalidParamsError ) as e: # This can occur if uncertainty data is missing or otherwise broken # print(e) - log.error(e) + logger.error(e) QtWidgets.QMessageBox.warning( self, "Could not perform Monte Carlo simulation", str(e) ) @@ -2167,7 +2167,7 @@ def calculate_gsa(self): except Exception as e: import traceback traceback.print_tb(e.__traceback__) - log.error(e) + logger.error(e) message = str(e) message_addition = "" if message == "singular matrix": @@ -2197,7 +2197,7 @@ def update_gsa(self, cs_name=None): self.table.table_name = "gsa_output_" + self.GSA.get_save_name() if self.checkbox_export_data_automatically.isChecked(): - log.info("EXPORTING DATA") + logger.info("EXPORTING DATA") self.GSA.export_GSA_input() self.GSA.export_GSA_output() @@ -2246,10 +2246,10 @@ def set_mc(self, mc, iterations=20): self.iterations = iterations def run(self): - log.info(f"Starting new Worker Thread. Iterations: {self.iterations}") + logger.info(f"Starting new Worker Thread. Iterations: {self.iterations}") self.mc.calculate(iterations=self.iterations) # res = bw.GraphTraversal().calculate(self.demand, self.method, self.cutoff, self.max_calc) - log.info("in thread {}".format(QtCore.QThread.currentThread())) + logger.info("in thread {}".format(QtCore.QThread.currentThread())) signals.monte_carlo_ready.emit(self.mc.cs_name) diff --git a/activity_browser/layouts/pages/lca_results/plots.py b/activity_browser/layouts/pages/lca_results/plots.py index 4ee5c695e..a1bc673db 100644 --- a/activity_browser/layouts/pages/lca_results/plots.py +++ b/activity_browser/layouts/pages/lca_results/plots.py @@ -1,5 +1,5 @@ import math -from logging import getLogger +from loguru import logger import matplotlib.pyplot as plt import numpy as np @@ -11,7 +11,7 @@ from activity_browser.bwutils.commontasks import wrap_text -log = getLogger(__name__) + class LCAResultsBarChart(ABPlot): diff --git a/activity_browser/layouts/pages/lca_results/tables.py b/activity_browser/layouts/pages/lca_results/tables.py index ad25d75bc..d575ea62a 100644 --- a/activity_browser/layouts/pages/lca_results/tables.py +++ b/activity_browser/layouts/pages/lca_results/tables.py @@ -1,7 +1,7 @@ import os import datetime from typing import Optional, Any -from logging import getLogger +from loguru import logger import arrow import numpy as np @@ -19,7 +19,7 @@ from .dialogs import FilterManagerDialog, SimpleFilterDialog -log = getLogger(__name__) + class CustomHeader(QtWidgets.QHeaderView): @@ -176,7 +176,7 @@ def data(self, index, role=Qt.DisplayRole): if role == Qt.ItemDataRole.CheckStateRole: value = self._dataframe.iat[index.row(), index.column()] if isinstance(value, str): - log.error(f"Expected bool, received str: {value}!!") + logger.error(f"Expected bool, received str: {value}!!") true_value = self._checkbox_editors[index.column()][1] # Convert the data to an appropriate value for the checkbox return Qt.CheckState.Checked if value == true_value else Qt.CheckState.Unchecked @@ -280,7 +280,7 @@ def test_query_on_column(test_type: str, col_data: pd.Series, query) -> bool: col_data.astype(float) <= float(query[1]) ) else: - log.warning("unknown filter type >{}<, assuming 'EQUALS'".format(test_type)) + logger.warning("unknown filter type >{}<, assuming 'EQUALS'".format(test_type)) return col_data == query def get_filter_mask(self, filters: dict) -> pd.Series: @@ -320,7 +320,7 @@ def get_filter_mask(self, filters: dict) -> pd.Series: new_mask = self.test_query_on_column(filt_type, col_data_, query) if not any(new_mask): # no matches for this mask, let user know: - log.info( + logger.info( "There were no matches for filter: {}: '{}'".format( col_filt[0], col_filt[1] ) @@ -435,7 +435,7 @@ def set_filters(self, mask) -> None: self.activate_filter = True self.invalidateFilter() self.activate_filter = False - log.info("{} filter matches found".format(self.matches)) + logger.info("{} filter matches found".format(self.matches)) def clear_filters(self) -> None: self.mask = None diff --git a/activity_browser/layouts/pages/parameters/base.py b/activity_browser/layouts/pages/parameters/base.py index f8e1f86f0..fd99f3e5c 100644 --- a/activity_browser/layouts/pages/parameters/base.py +++ b/activity_browser/layouts/pages/parameters/base.py @@ -1,7 +1,7 @@ import os import datetime from typing import Optional, Any -from logging import getLogger +from loguru import logger import arrow import numpy as np @@ -16,7 +16,7 @@ from activity_browser.settings import ab_settings from activity_browser.ui import icons, widgets, delegates -log = getLogger(__name__) + class ABSortProxyModel(QSortFilterProxyModel): @@ -628,7 +628,7 @@ def set_filters(self, mask) -> None: self.activate_filter = True self.invalidateFilter() self.activate_filter = False - log.info("{} filter matches found".format(self.matches)) + logger.info("{} filter matches found".format(self.matches)) def clear_filters(self) -> None: self.mask = None @@ -772,7 +772,7 @@ def data(self, index, role=Qt.DisplayRole): if role == Qt.ItemDataRole.CheckStateRole: value = self._dataframe.iat[index.row(), index.column()] if isinstance(value, str): - log.error(f"Expected bool, received str: {value}!!") + logger.error(f"Expected bool, received str: {value}!!") true_value = self._checkbox_editors[index.column()][1] # Convert the data to an appropriate value for the checkbox return Qt.CheckState.Checked if value == true_value else Qt.CheckState.Unchecked @@ -877,7 +877,7 @@ def test_query_on_column( col_data.astype(float) <= float(query[1]) ) else: - log.warning("unknown filter type >{}<, assuming 'EQUALS'".format(test_type)) + logger.warning("unknown filter type >{}<, assuming 'EQUALS'".format(test_type)) return col_data == query def get_filter_mask(self, filters: dict) -> pd.Series: @@ -917,7 +917,7 @@ def get_filter_mask(self, filters: dict) -> pd.Series: new_mask = self.test_query_on_column(filt_type, col_data_, query) if not any(new_mask): # no matches for this mask, let user know: - log.info( + logger.info( "There were no matches for filter: {}: '{}'".format( col_filt[0], col_filt[1] ) diff --git a/activity_browser/layouts/pages/parameters/parameter_models.py b/activity_browser/layouts/pages/parameters/parameter_models.py index 9277defe5..8540865b7 100644 --- a/activity_browser/layouts/pages/parameters/parameter_models.py +++ b/activity_browser/layouts/pages/parameters/parameter_models.py @@ -1,6 +1,6 @@ import itertools from typing import Iterable, Tuple -from logging import getLogger +from loguru import logger import pandas as pd import numpy as np @@ -18,7 +18,7 @@ from .base import BaseTreeModel, EditablePandasModel, TreeItem, PandasModel -log = getLogger(__name__) + class BaseParameterModel(EditablePandasModel): @@ -237,7 +237,7 @@ def parse_parameter(cls, parameter) -> dict: act = bd.get_activity(row["key"]) except: # Can occur if an activity parameter exists for a removed activity. - log.info( + logger.info( "Activity {} no longer exists, removing parameter.".format(row["key"]) ) actions.ParameterClearBroken.run(parameter) @@ -363,7 +363,7 @@ def build_exchanges(cls, act_param, parent: TreeItem) -> None: parent.appendChild(item) except DoesNotExist as e: # The exchange is coming from a deleted database, remove it - log.warning(f"Broken exchange: {exc}, removing.") + logger.warning(f"Broken exchange: {exc}, removing.") actions.ExchangeDelete.run([exc]) diff --git a/activity_browser/layouts/panes/database_explorer.py b/activity_browser/layouts/panes/database_explorer.py index 17159f633..e5777818f 100644 --- a/activity_browser/layouts/panes/database_explorer.py +++ b/activity_browser/layouts/panes/database_explorer.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger import pandas as pd from qtpy import QtWidgets, QtCore, QtGui @@ -10,7 +10,7 @@ from activity_browser.ui import widgets from activity_browser.ui.core import application -log = getLogger(__name__) + COLUMNS = ["name", "type", "exchanges", "database", "code"] DETAILS_COLUMNS = ["input", "output", "type", "amount"] diff --git a/activity_browser/layouts/panes/database_products.py b/activity_browser/layouts/panes/database_products.py index 676bfc28b..36256c434 100644 --- a/activity_browser/layouts/panes/database_products.py +++ b/activity_browser/layouts/panes/database_products.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from time import time import pandas as pd @@ -12,7 +12,7 @@ from activity_browser.ui import core, widgets, delegates from activity_browser.bwutils import AB_metadata, database_is_locked, database_is_legacy -log = getLogger(__name__) + DEFAULT_STATE = { "columns": ["activity", "product", "Type", "Unit", "Location"], @@ -147,7 +147,7 @@ def sync(self): else: self.table_view.showColumn(index) - log.debug(f"Synced DatabaseProductsPane in {time() - t:.2f} seconds") + logger.debug(f"Synced DatabaseProductsPane in {time() - t:.2f} seconds") def build_df(self) -> pd.DataFrame: """ @@ -178,7 +178,7 @@ def build_df(self) -> pd.DataFrame: cols = ["name", "product", "categories", "unit", "location", "key", "processor", "type",] cols += [col for col in df.columns if col.startswith("property")] - log.debug(f"Built DatabaseProductsPane dataframe in {time() - t:.2f} seconds") + logger.debug(f"Built DatabaseProductsPane dataframe in {time() - t:.2f} seconds") return df[cols] diff --git a/activity_browser/layouts/panes/project_manager.py b/activity_browser/layouts/panes/project_manager.py index ed78ebf9c..7b224ce82 100644 --- a/activity_browser/layouts/panes/project_manager.py +++ b/activity_browser/layouts/panes/project_manager.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger import pandas as pd from qtpy import QtWidgets, QtCore @@ -11,7 +11,7 @@ from activity_browser.ui import widgets -log = getLogger(__name__) + class ProjectManagerPane(widgets.ABAbstractPane): @@ -59,7 +59,7 @@ def build_project_df(self) -> pd.DataFrame: for proj_ds in sorted(bd.projects): # if for any reason the project data is not a dictionary, log a warning and set it to an empty dict if not isinstance(proj_ds.data, dict): - log.warning(f"Project {proj_ds.name} has no data dictionary") + logger.warning(f"Project {proj_ds.name} has no data dictionary") proj_ds.data = {} data[proj_ds.name] = { diff --git a/activity_browser/logger.py b/activity_browser/logger.py deleted file mode 100644 index 31c6402db..000000000 --- a/activity_browser/logger.py +++ /dev/null @@ -1,248 +0,0 @@ -import logging -import os -import time -import sys -from traceback import extract_tb -from types import TracebackType -from typing import Type - -import platformdirs - - -class ABFileHandler(logging.Handler): - """ - LogHandler for the log files. Formats them in semicolon separated CSV files for easy reading. - """ - - headers = [ - "time", - "type", - "thread", - "name", - "file location", - "line number", - "function name", - "message", - ] - - def __init__(self): - super().__init__() - - # create a unique filename based on the datetime - self.filename = "ab_logs" + self.timestamp() + ".csv" - - # set dir and create it if it doesn't exist yet - dir_path = str(platformdirs.user_log_dir(appname="ActivityBrowser", appauthor="ActivityBrowser")) - os.makedirs(dir_path, exist_ok=True) - - # create final filepath of the logfile of this session - self.filepath = os.path.join(dir_path, self.filename) - - # set the global file location - global log_file_location - log_file_location = self.filepath - - # create the logfile and write the headers - with open(self.filepath, "a", encoding='utf-8') as log_file: - log_file.write(";".join(self.headers) + "\n") - - def emit(self, record: logging.LogRecord): - """Handle a new LogRecord""" - # format the message from the record - message = self.format(record) - - # append to the logfile - with open(self.filepath, "a", encoding='utf-8') as log_file: - log_file.write(message) - - # if there's exception info, write the exception traceback to the file as well - if record.exc_info: - exc_message = self.format_exception(record.exc_info[2]) - log_file.write(exc_message) - - def format(self, record: logging.LogRecord) -> str: - """Format a LogRecord""" - # format message to a single line - message = " ".join(str(record.msg).split("\n")) - message = message + " ".join([str(arg) for arg in record.args]) - - # if there is no message left, return nothing - if message == " ": - return "" - - # make sure there a no semicolons - message.replace(";", ":") - - # convert time - struct_time = time.localtime(record.created) - readable_time = time.strftime("%H:%M.%S", struct_time) - - line = f"{readable_time};{record.levelname};{record.threadName};{record.name};{record.pathname};{record.lineno};{record.funcName};{message}" - return f"{line}\n" - - def format_exception(self, traceback: TracebackType) -> str: - """Format the traceback of an exception""" - # extract the traceback - traceback = extract_tb(traceback) - message = "" - - # append a line for each frame in the traceback - for frame in traceback: - line = f";TRACEBACK;;;{frame.filename};{frame.lineno};{frame.name};{frame.line}" - message = f"{message}{line}\n" - - # return the string containing multiple lines - return message - - def timestamp(self) -> str: - """Return a timestamped string, the format provided is: - day of the year _ month _ day - hour _ minute _ second""" - stmp = time.localtime() - return f"-{stmp.tm_year}-{stmp.tm_mon}-{stmp.tm_mday}_{stmp.tm_hour}-{stmp.tm_min}-{stmp.tm_sec}" - - -class ABPycharmHandler(logging.Handler): - """ - LogHandler for the console. Make sure they are all in the same format. Adds badges, and if extended logs are enabled - also the time and a (shortened) logger name. - """ - - badge = { - "INFO": "\u001b[48;5;24m\u001b[38;5;255m INFO \u001b[0m", - "DEBUG": "\u001b[48;5;90m\u001b[38;5;255m DEBUG \u001b[0m", - "EXCEPTION": "\u001b[48;5;88m\u001b[38;5;255m EXCPT \u001b[0m", - "ERROR": "\u001b[48;5;88m\u001b[38;5;255m ERROR \u001b[0m", - "WARNING": "\u001b[48;5;130m\u001b[38;5;255m WARN \u001b[0m", - "PRINT": "\u001b[7m PRINT \u001b[0m", - } - - alias = {"activity_browser": "AB", "brightway2": "BW2"} - - def __init__(self): - super().__init__() - # create a unique filename based on the datetime - self.filename = "pycharm_logs.log" - - # set dir and create it if it doesn't exist yet - dir_path = platformdirs.user_log_dir("ActivityBrowser", "ActivityBrowser") - os.makedirs(dir_path, exist_ok=True) - - # create final filepath of the logfile of this session - self.filepath = os.path.join(dir_path, self.filename) - - def emit(self, record: logging.LogRecord): - """Handle a new LogRecord""" - # format message - message = self.format_log(record) - - # append to the logfile - with open(self.filepath, "a", encoding='utf-8') as log_file: - log_file.write(message) - - # if there's exception info, write the exception traceback to the file as well - if record.exc_info: - exc_message = self.format_exception(record.exc_info[2]) - log_file.write(exc_message) - - def format_log(self, record: logging.LogRecord) -> str: - """Format a LogRecord""" - # format message to a single line - message = " ".join(str(record.msg).split("\n")) - message = message + " ".join([str(arg) for arg in record.args]) - - # if there is no message left, return nothing - if message == " ": - return "" - - # clean-up if the message is a C++ error message - if message.startswith("[") and message.index(":") < message.index("]"): - # most likely a c++ error log, otherwise, very bad luck - i = message.index("]") + 2 - message = message[i:] - - # retrieve the badge - badge = self.badge[record.levelname] - if record.exc_info: - badge = self.badge["EXCEPTION"] - - # get a clean timestamp - time_stamp = time.asctime()[11:19] - - source_str = self.format_source(record.name) - - return f"{time_stamp} {source_str}{badge} {message}\n" - - def format_source(self, name: str) -> str: - """ - The entire source may be too long for the console window. Here we replace known sources with their alias, only - use the first two modules and cut it short if it's still too long - """ - # create list of the module string - module_split = name.split(".") - - # switch a possible alias - for key, alias in self.alias.items(): - if key == module_split[0]: - module_split[0] = alias - - # rebuild only the first two modules - source_str = ".".join(module_split[:2]) - - # adjust length - if len(source_str) >= 20: - source_str = source_str[:16] + "..." - - return source_str.ljust(20) - - def format_exception(self, traceback: TracebackType) -> str: - """Format the traceback of an exception""" - space = 37 - - traceback = extract_tb(traceback) - message = "\u001b[38;5;1m\u001b[1m" - for frame in traceback: - line1 = f'{space * " "}File "{frame.filename}", line {frame.lineno}, in {frame.name}\n' - line2 = f"{space * ' '} {frame.line}\n" - message = message + line1 + line2 - message = f"{message}\u001b[0m" - return message - - -def exception_hook( - error: Type[BaseException], message: BaseException, traceback: TracebackType -): - """Exception hook to catch and log exceptions""" - exc_info = (error, message, traceback) - log = logging.getLogger("exception_hook") - log.exception(f"{error.__name__}: {message}", exc_info=exc_info) - - -def setup_ab_logging(): - # set the root logger's level to 0, this gives us access to all logs - logging.root.setLevel(0) - - # peewee is mad, so set to info - logging.getLogger("peewee").setLevel("INFO") - - # setting up a basic stderr handler - stderr_handler = logging.StreamHandler() - formatter = logging.Formatter( - "%(asctime)s | %(levelname)s | %(message)s", "%H:%M:%S" - ) - stderr_handler.setFormatter(formatter) - stderr_handler.setLevel("DEBUG") - logging.root.addHandler(stderr_handler) - - # # setting up the pycharm handler - # pycharm_handler = ABPycharmHandler() - # logging.root.addHandler(pycharm_handler) - - # setting up the file handler - file_handler = ABFileHandler() - logging.root.addHandler(file_handler) - - # setting up the exception hook - sys.excepthook = exception_hook - - -log_file_location = None diff --git a/activity_browser/mod/bw2io/__init__.py b/activity_browser/mod/bw2io/__init__.py index 236e1b081..f1f95b369 100644 --- a/activity_browser/mod/bw2io/__init__.py +++ b/activity_browser/mod/bw2io/__init__.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from bw2io import * @@ -7,7 +7,7 @@ -log = getLogger(__name__) + def ab_bw2setup(version): @@ -22,18 +22,18 @@ def ab_bw2setup(version): version = version[:3] if version == sort_semantic_versions(__ei_versions__)[0][:3]: - log.info(f"Installing biosphere version >{version}<") + logger.info(f"Installing biosphere version >{version}<") # most recent version bio_import = ABEcospold2BiosphereImporter() else: - log.info(f"Installing legacy biosphere version >{version}<") + logger.info(f"Installing legacy biosphere version >{version}<") # not most recent version, import legacy biosphere from AB bio_import = ABEcospold2BiosphereImporter(version=version) bio_import.apply_strategies() - log.info("Writing biosphere database") + logger.info("Writing biosphere database") bio_import.write_database() - log.info("Writing LCIA methods") + logger.info("Writing LCIA methods") create_default_lcia_methods() # patching biosphere @@ -51,7 +51,7 @@ def ab_bw2setup(version): ] for patch in patches: - log.info(f"Applying biosphere patch: {patch}") + logger.info(f"Applying biosphere patch: {patch}") update_bio = getattr(bi.data, patch) update_bio() diff --git a/activity_browser/mod/bw2io/ecoinvent.py b/activity_browser/mod/bw2io/ecoinvent.py index adb5e6563..9f950ca3d 100644 --- a/activity_browser/mod/bw2io/ecoinvent.py +++ b/activity_browser/mod/bw2io/ecoinvent.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from bw2io.ecoinvent import * @@ -7,7 +7,7 @@ from activity_browser.mod.ecoinvent_interface.release import ABEcoinventRelease from activity_browser.mod.bw2io.importers.ecospold2_biosphere import ABEcospold2BiosphereImporter -log = getLogger(__name__) + def ab_import_ecoinvent_release(version, system_model): @@ -32,27 +32,27 @@ def ab_import_ecoinvent_release(version, system_model): name="biosphere3", filepath=lci_path / "MasterData" / "ElementaryExchanges.xml", ) - log.info("Applying strategies") + logger.info("Applying strategies") bio_import.apply_strategies() - log.info("Writing biosphere database") + logger.info("Writing biosphere database") bio_import.write_database() bd.preferences["biosphere_database"] = "biosphere3" # importing ecoinvent through a ecospold2 importer that implements a progress_slot - log.info("Importing ecoinvent") + logger.info("Importing ecoinvent") db_name = f"ecoinvent-{version}-{system_model}" ei_import = SingleOutputEcospold2Importer( dirpath=str(lci_path / "datasets"), db_name=db_name, biosphere_database_name="biosphere3", ) - log.info("Applying strategies") + logger.info("Applying strategies") ei_import.apply_strategies() - log.info("Writing ecoinvent database") + logger.info("Writing ecoinvent database") ei_import.write_database() # importing all LCIA methods - log.info("Gathering LCIA methods") + logger.info("Gathering LCIA methods") lcia_file = ei.get_excel_lcia_file_for_version(release=release, version=version) sheet_names = get_excel_sheet_names(lcia_file) @@ -69,11 +69,11 @@ def ab_import_ecoinvent_release(version, system_model): raise ValueError( f"Can't find worksheet for characterization factors; expected `CFs`, found {sheet_names}" ) - log.info("Extracting LCIA methods") + logger.info("Extracting LCIA methods") data = dict(ExcelExtractor.extract(lcia_file)) units = header_dict(data[units_sheetname]) - log.info("Mapping LCIA methods") + logger.info("Mapping LCIA methods") cfs = header_dict(data["CFs"]) CF_COLUMN_LABELS = { diff --git a/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py b/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py index f8d9fb065..dfae9f34e 100644 --- a/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py +++ b/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py @@ -2,7 +2,7 @@ from bw2io.importers.ecospold2_biosphere import * import pyprind -import logging +from loguru import logger import os from activity_browser.info import __ei_versions__ @@ -111,5 +111,5 @@ def apply_strategies(self, strategies=None, verbose=True): self.apply_strategy(func, verbose) def write_database(self, *args, **kwargs): - logging.getLogger(__name__).info("Writing Biosphere database") + logger.info("Writing Biosphere database") super().write_database(*args, **kwargs) diff --git a/activity_browser/settings.py b/activity_browser/settings.py index 43ef58bb6..b74453a30 100644 --- a/activity_browser/settings.py +++ b/activity_browser/settings.py @@ -4,7 +4,7 @@ import shutil from pathlib import Path from typing import Optional, Any -from logging import getLogger +from loguru import logger import bw2data as bd @@ -13,7 +13,7 @@ from .signals import signals -log = getLogger(__name__) + DEFAULT_BW_DATA_DIR = bd.projects._base_data_dir @@ -84,7 +84,7 @@ def __init__(self, filename: str): super().__init__(ab_dir, filename) if not self.healthy(): - log.warn("Settings health check failed, resetting") + logger.warn("Settings health check failed, resetting") self.restore_default_settings() def healthy(self) -> bool: @@ -268,7 +268,7 @@ def reset_for_project_selection(self) -> None: """On switching project, attempt to read the settings for the new project. """ - log.info(f"Project settings directory: {bd.projects.dir}") + logger.info(f"Project settings directory: {bd.projects.dir}") bd.projects.dir.joinpath("activity_browser").mkdir(exist_ok=True) diff --git a/activity_browser/signals.py b/activity_browser/signals.py index 0915e0952..2f032d993 100644 --- a/activity_browser/signals.py +++ b/activity_browser/signals.py @@ -1,10 +1,10 @@ -from logging import getLogger +from loguru import logger from time import time from qtpy.QtCore import QObject, Signal, SignalInstance from blinker import signal as blinker_signal -log = getLogger(__name__) + class NodeSignals(QObject): @@ -145,17 +145,17 @@ def _on_signaleddataset_on_save(self, sender, old, new): if isinstance(new, ActivityDataset): t = time() self.node.changed.emit(new, old) - log.debug(f"Activity changed signal completed in {time() - t:.2f} seconds") + logger.debug(f"Activity changed signal completed in {time() - t:.2f} seconds") elif isinstance(new, ExchangeDataset): t = time() self.edge.changed.emit(new, old) - log.debug(f"Exchange changed signal completed in {time() - t:.2f} seconds") + logger.debug(f"Exchange changed signal completed in {time() - t:.2f} seconds") elif isinstance(new, (ProjectParameter, DatabaseParameter, ActivityParameter)): t = time() self.parameter.changed.emit(new, old) - log.debug(f"Parameter changed signal completed in {time() - t:.2f} seconds") + logger.debug(f"Parameter changed signal completed in {time() - t:.2f} seconds") else: - log.debug(f"Unknown dataset type changed: {type(new)}") + logger.debug(f"Unknown dataset type changed: {type(new)}") def _on_signaleddataset_on_delete(self, sender, old): from bw2data.backends import ActivityDataset, ExchangeDataset @@ -164,90 +164,90 @@ def _on_signaleddataset_on_delete(self, sender, old): if isinstance(old, ActivityDataset): t = time() self.node.deleted.emit(old) - log.debug(f"Activity deleted signal completed in {time() - t:.2f} seconds") + logger.debug(f"Activity deleted signal completed in {time() - t:.2f} seconds") elif isinstance(old, ExchangeDataset): t = time() self.edge.deleted.emit(old) - log.debug(f"Exchange deleted signal completed in {time() - t:.2f} seconds") + logger.debug(f"Exchange deleted signal completed in {time() - t:.2f} seconds") elif isinstance(old, (ProjectParameter, DatabaseParameter, ActivityParameter)): t = time() self.parameter.deleted.emit(old) - log.debug(f"Parameter deleted signal completed in {time() - t:.2f} seconds") + logger.debug(f"Parameter deleted signal completed in {time() - t:.2f} seconds") else: - log.debug(f"Unknown dataset type deleted: {type(old)}") + logger.debug(f"Unknown dataset type deleted: {type(old)}") def _on_activity_database_change(self, sender, old, new): t = time() self.node.database_change.emit(old, new) - log.debug(f"Activity db changed signal completed in {time() - t:.2f} seconds") + logger.debug(f"Activity db changed signal completed in {time() - t:.2f} seconds") def _on_activity_code_change(self, sender, old, new): t = time() self.node.code_change.emit(old, new) - log.debug(f"Activity code changed signal completed in {time() - t:.2f} seconds") + logger.debug(f"Activity code changed signal completed in {time() - t:.2f} seconds") def _on_database_delete(self, sender, name): t = time() self.database.deleted.emit(name) - log.debug(f"Database deleted signal completed in {time() - t:.2f} seconds") + logger.debug(f"Database deleted signal completed in {time() - t:.2f} seconds") def _on_database_reset(self, sender, name): from bw2data import Database t = time() self.database.reset.emit(Database(name)) - log.debug(f"Database reset signal completed in {time() - t:.2f} seconds") + logger.debug(f"Database reset signal completed in {time() - t:.2f} seconds") def _on_database_write(self, sender, name): from bw2data import Database t = time() self.database.written.emit(Database(name)) - log.debug(f"Database write signal completed in {time() - t:.2f} seconds") + logger.debug(f"Database write signal completed in {time() - t:.2f} seconds") def _on_project_changed(self, ds): t = time() self.project.changed.emit(ds, self._project_dataset) self._project_dataset = ds - log.debug(f"Project changed signal completed in {time() - t:.2f} seconds") + logger.debug(f"Project changed signal completed in {time() - t:.2f} seconds") def _on_project_created(self, ds): t = time() self.project.created.emit() - log.debug(f"Project created signal completed in {time() - t:.2f} seconds") + logger.debug(f"Project created signal completed in {time() - t:.2f} seconds") def _on_database_metadata_change(self, sender, old, new): t = time() self.meta.databases_changed.emit(old, new) - log.debug(f"DB metadata changed signal completed in {time() - t:.2f} seconds") + logger.debug(f"DB metadata changed signal completed in {time() - t:.2f} seconds") def _on_methods_metadata_change(self, sender, old, new): t = time() self.meta.methods_changed.emit(old, new) - log.debug(f"Methods metadata changed signal completed in {time() - t:.2f} seconds") + logger.debug(f"Methods metadata changed signal completed in {time() - t:.2f} seconds") def _on_cs_metadata_change(self, sender, old, new): t = time() self.meta.calculation_setups_changed.emit(old, new) - log.debug(f"CS metadata changed signal completed in {time() - t:.2f} seconds") + logger.debug(f"CS metadata changed signal completed in {time() - t:.2f} seconds") def _on_method_write(self, sender): t = time() self.method.changed.emit(sender) - log.debug(f"Method changed signal completed in {time() - t:.2f} seconds") + logger.debug(f"Method changed signal completed in {time() - t:.2f} seconds") def _on_method_deregister(self, sender): t = time() self.method.deleted.emit(sender) - log.debug(f"Method deleted signal completed in {time() - t:.2f} seconds") + logger.debug(f"Method deleted signal completed in {time() - t:.2f} seconds") def _on_parameter_recalculate(self, sender, *args, **kwargs): t = time() self.parameter.recalculated.emit() - log.debug(f"Param recalculated signal completed in {time() - t:.2f} seconds") + logger.debug(f"Param recalculated signal completed in {time() - t:.2f} seconds") def _on_parameterized_exchange_recalculate(self, sender, *args, **kwargs): t = time() self.edge.recalculated.emit() - log.debug(f"Param exchange recalculated signal completed in {time() - t:.2f} seconds") + logger.debug(f"Param exchange recalculated signal completed in {time() - t:.2f} seconds") def patch_methods_datastore(): diff --git a/activity_browser/ui/core/application.py b/activity_browser/ui/core/application.py index 8a262a6f9..c7bb0238e 100644 --- a/activity_browser/ui/core/application.py +++ b/activity_browser/ui/core/application.py @@ -1,5 +1,5 @@ from pathlib import Path -from logging import getLogger +from loguru import logger from qtpy import QtGui, QtWidgets, QtCore, PYSIDE6 from qtpy.QtCore import Qt @@ -7,7 +7,7 @@ from activity_browser.static import fonts, icons -log = getLogger(__name__) + class ABApplication(QtWidgets.QApplication): diff --git a/activity_browser/ui/core/threading.py b/activity_browser/ui/core/threading.py index 6de0f0818..7fca4582a 100644 --- a/activity_browser/ui/core/threading.py +++ b/activity_browser/ui/core/threading.py @@ -1,5 +1,5 @@ import threading -import logging +from loguru import logger from qtpy.QtCore import QThread, SignalInstance, Signal from qtpy import QtWidgets @@ -84,30 +84,38 @@ def __exit__(self, *args): class InfoToSlot: def __init__(self, progress_slot=lambda progress, message: None): - self.handler = LoggingProgressHandler("INFO") + self.sink = LoggingProgressSink("INFO") thread_local.progress_slot = progress_slot + self._sink_id = None def __enter__(self): - logging.root.addHandler(self.handler) + # Attach a loguru sink which forwards INFO logs from this thread to the progress slot + self._sink_id = logger.add(self.sink, level="INFO") return def __exit__(self, *args): - logging.root.removeHandler(self.handler) + if self._sink_id is not None: + try: + logger.remove(self._sink_id) + except Exception: + pass return +class LoggingProgressSink: + def __init__(self, level="INFO"): + self.level = level -class LoggingProgressHandler(logging.Handler): - def filter(self, record: logging.LogRecord) -> bool: - if record.thread != threading.get_ident(): - return False - if record.levelname != "INFO": - return False - return True - - def emit(self, record: logging.LogRecord): + def __call__(self, message): + record = message.record try: - thread_local.progress_slot(None, record.message) + # Only handle messages from the current thread and matching level + if record["level"].name != self.level: + return + if record["thread"].id != threading.get_ident(): + return + thread_local.progress_slot(None, record.get("message", "")) except AttributeError: + # No progress slot set or malformed record pass diff --git a/activity_browser/ui/dialogs/uncertainty.py b/activity_browser/ui/dialogs/uncertainty.py index 0341bda51..97c476647 100644 --- a/activity_browser/ui/dialogs/uncertainty.py +++ b/activity_browser/ui/dialogs/uncertainty.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger import numpy as np import seaborn as sns @@ -14,7 +14,7 @@ from ...bwutils import PedigreeMatrix, get_uncertainty_interface from ...bwutils.uncertainty import EMPTY_UNCERTAINTY -log = getLogger(__name__) + class UncertaintyWizard(QtWidgets.QWizard): @@ -637,7 +637,7 @@ def initializePage(self): matrix = PedigreeMatrix.from_dict(obj.uncertainty.get("pedigree", {})) self.pedigree = matrix.factors except AssertionError as e: - log.info("Could not extract pedigree data: {}".format(str(e))) + logger.info("Could not extract pedigree data: {}".format(str(e))) self.pedigree = {} self.check_complete() @@ -722,7 +722,7 @@ def plot(self, data: np.ndarray, mean: float, label: str = "Value"): try: sns.histplot(data.T, kde=True, stat="density", ax=self.ax, edgecolor="none") except RuntimeError as e: - log.error("{}: Plotting without KDE.".format(e)) + logger.error("{}: Plotting without KDE.".format(e)) sns.histplot( data.T, kde=False, stat="density", ax=self.ax, edgecolor="none" ) diff --git a/activity_browser/ui/dialogs/uncertainty_dialog.py b/activity_browser/ui/dialogs/uncertainty_dialog.py index 0f5722681..bf0eaa434 100644 --- a/activity_browser/ui/dialogs/uncertainty_dialog.py +++ b/activity_browser/ui/dialogs/uncertainty_dialog.py @@ -1,6 +1,6 @@ from __future__ import annotations -from logging import getLogger +from loguru import logger from typing import Optional, Tuple import numpy as np @@ -11,7 +11,7 @@ from activity_browser.ui.widgets import ABPlot -log = getLogger(__name__) + EMPTY_UNCERTAINTY = { @@ -435,7 +435,7 @@ def _generate_plot(self) -> None: try: self.plot.plot(data, vline) except RuntimeError as e: - log.error("%s: plotting failed, retry without KDE", e) + logger.error("%s: plotting failed, retry without KDE", e) try: sns.histplot(data.T, kde=False, stat="density", ax=self.plot.ax, edgecolor="none") self.plot.ax.axvline(vline, label="Mean / amount", c="r", ymax=0.98) @@ -467,7 +467,7 @@ def plot(self, data: np.ndarray, mean: float, label: str = "Value"): try: sns.histplot(data.T, kde=True, stat="density", ax=self.ax, edgecolor="none") except RuntimeError as e: - log.error("%s: Plotting without KDE.", e) + logger.error("%s: Plotting without KDE.", e) sns.histplot(data.T, kde=False, stat="density", ax=self.ax, edgecolor="none") self.ax.set_xlabel(label) self.ax.set_ylabel("Probability density") diff --git a/activity_browser/ui/web/base.py b/activity_browser/ui/web/base.py index 955406fa5..16d25e574 100644 --- a/activity_browser/ui/web/base.py +++ b/activity_browser/ui/web/base.py @@ -3,7 +3,7 @@ from abc import abstractmethod from copy import deepcopy from typing import Type -from logging import getLogger +from loguru import logger from qtpy import QtWebChannel, QtWebEngineWidgets, QtWidgets from qtpy.QtCore import QObject, Qt, QUrl, Signal, Slot @@ -17,7 +17,7 @@ from . import webutils from .webengine_page import Page -log = getLogger(__name__) + class BaseNavigatorWidget(QtWidgets.QWidget): @@ -151,7 +151,7 @@ def node_clicked(self, click_text: str): click_dict["database"], click_dict["id"], ) # since JSON does not know tuples - log.info(f"Click information: {click_dict}") # TODO click_dict needs correcting + logger.info(f"Click information: {click_dict}") # TODO click_dict needs correcting self.update_graph.emit(click_dict) @Slot(str, name="download_triggered") diff --git a/activity_browser/ui/web/navigator.py b/activity_browser/ui/web/navigator.py index cd9ff967f..54942d0f0 100644 --- a/activity_browser/ui/web/navigator.py +++ b/activity_browser/ui/web/navigator.py @@ -3,7 +3,7 @@ import os from copy import deepcopy from typing import Optional -from logging import getLogger +from loguru import logger import networkx as nx from qtpy import QtWidgets @@ -16,7 +16,7 @@ from ...bwutils.commontasks import identify_activity_type, get_activity_name from .base import BaseGraph, BaseNavigatorWidget -log = getLogger(__name__) + # TODO: @@ -126,7 +126,7 @@ def sync_graph(self): try: self.setObjectName(get_activity_name(get_activity(self.key), str_length=30)) except ActivityDataset.DoesNotExist: - log.debug("Graph activity no longer exists. Closing tab.") + logger.debug("Graph activity no longer exists. Closing tab.") self.tab.close_tab_by_tab_name(self.tab.get_tab_name(self)) def construct_layout(self) -> None: @@ -187,12 +187,12 @@ def is_expansion_mode(self) -> bool: def toggle_navigation_mode(self): mode = next(self.navigation_label) self.button_navigation_mode.setText(mode) - log.info(f"Switched to: {mode}") + logger.info(f"Switched to: {mode}") self.checkbox_remove_orphaned_nodes.setVisible(self.is_expansion_mode) self.checkbox_direct_only.setVisible(self.is_expansion_mode) def new_graph(self, key: tuple) -> None: - log.info(f"New Graph for key: {key}") + logger.info(f"New Graph for key: {key}") self.graph.new_graph(key) self.send_json() @@ -221,15 +221,15 @@ def update_graph(self, click_dict: dict) -> None: self.new_graph(key) else: if keyboard["alt"]: # delete node - log.info(f"Deleting node: {key}") + logger.info(f"Deleting node: {key}") self.graph.reduce_graph(key) else: # expansion mode - log.info(f"Expanding graph: {key}") + logger.info(f"Expanding graph: {key}") if keyboard["shift"]: # downstream expansion - log.info("Adding downstream nodes.") + logger.info("Adding downstream nodes.") self.graph.expand_graph(key, down=True) else: # upstream expansion - log.info("Adding upstream nodes.") + logger.info("Adding upstream nodes.") self.graph.expand_graph(key, up=True) self.send_json() @@ -280,7 +280,7 @@ def update_datasets(self): get_activity(self.central_activity.key) # test whether the activity still exists self.new_graph(self.central_activity.key) # if so, create a new graph except ActivityDataset.DoesNotExist: - log.warning("Graph activity no longer exists.") + logger.warning("Graph activity no longer exists.") self.nodes = [] self.edges = [] @@ -387,7 +387,7 @@ def reduce_graph(self, key: tuple) -> None: Can lead to orphaned nodes, which can be removed or kept. """ if key == self.central_activity.key: - log.warning("Cannot remove central activity.") + logger.warning("Cannot remove central activity.") return act = get_activity(key) self.nodes.remove(act) @@ -433,7 +433,7 @@ def format_as_weighted_edges(exchanges, activity_objects=False): for count, key in enumerate(orphaned_node_ids, 1): act = get_activity(key) self.nodes.remove(act) - log.info(f"Removed ORPHANED nodes: {count}") + logger.info(f"Removed ORPHANED nodes: {count}") # update edges again to remove those that link to nodes that have been deleted self.remove_outside_exchanges() @@ -449,7 +449,7 @@ def get_json_data(self) -> Optional[str]: A JSON representation of this. """ if not self.nodes: - log.info("Graph has no nodes (activities).") + logger.info("Graph has no nodes (activities).") return data = { diff --git a/activity_browser/ui/web/sankey_navigator.py b/activity_browser/ui/web/sankey_navigator.py index 4cf6c2067..13e84db80 100644 --- a/activity_browser/ui/web/sankey_navigator.py +++ b/activity_browser/ui/web/sankey_navigator.py @@ -3,7 +3,7 @@ import os import time from typing import List -from logging import getLogger +from loguru import logger import bw2calc as bc import bw2data as bd @@ -22,7 +22,7 @@ from ...bwutils.commontasks import identify_activity_type from .base import BaseGraph, BaseNavigatorWidget -log = getLogger(__name__) + # TODO: @@ -239,14 +239,14 @@ def update_sankey( cache_key = (demand_index, method_index, scenario_index, cut_off, max_calc) if data := self.cache.get(cache_key, False): # this Sankey is already cached, generate the Sankey with the cached data - log.debug(f"CACHED sankey for: {demand}, {method}, key: {cache_key}") + logger.debug(f"CACHED sankey for: {demand}, {method}, key: {cache_key}") self.graph.new_graph(data) self.has_sankey = bool(self.graph.json_data) self.send_json() return start = time.time() - log.debug(f"CALCULATE sankey for: {demand}, {method}, key: {cache_key}") + logger.debug(f"CALCULATE sankey for: {demand}, {method}, key: {cache_key}") try: if scenario_lca: self.parent.mlca.update_lca_calculation_for_sankey( @@ -274,7 +274,7 @@ def update_sankey( QtWidgets.QMessageBox.information( None, "Nonsensical numeric result.", str(e) ) - log.debug(f"Completed graph traversal ({round(time.time() - start, 2)} seconds") + logger.debug(f"Completed graph traversal ({round(time.time() - start, 2)} seconds") # cache the generated Sankey data self.cache[cache_key] = data diff --git a/activity_browser/ui/web/tree_navigator.py b/activity_browser/ui/web/tree_navigator.py index 2a3b566c0..1c48c37d2 100644 --- a/activity_browser/ui/web/tree_navigator.py +++ b/activity_browser/ui/web/tree_navigator.py @@ -1,7 +1,7 @@ import json import time from typing import List, Optional -from logging import getLogger +from loguru import logger import bw2calc as bc import bw2data as bd @@ -29,7 +29,7 @@ from ...bwutils import AB_metadata from ...bwutils.commontasks import identify_activity_type -log = getLogger(__name__) + class SmallComboBox(QtWidgets.QComboBox): """A small combo box that does not expand to fill the available space.""" @@ -247,14 +247,14 @@ def update_tree( ) if data := self.cache.get(cache_key, False): # this Graph is already cached, generate the tree with Graph cached data - log.debug(f"CACHED tree for: {demand}, {method}, key: {cache_key}") + logger.debug(f"CACHED tree for: {demand}, {method}, key: {cache_key}") self.graph.new_graph(data) self.has_rendered_once = bool(self.graph.json_data) self.send_json() return start = time.time() - log.debug(f"CALCULATE tree for: {demand}, {method}, key: {cache_key}") + logger.debug(f"CALCULATE tree for: {demand}, {method}, key: {cache_key}") try: if scenario_lca: @@ -291,7 +291,7 @@ def update_tree( QtWidgets.QMessageBox.information( None, "Nonsensical numeric result.", str(e) ) - log.debug(f"Completed graph traversal ({round(time.time() - start, 2)} seconds") + logger.debug(f"Completed graph traversal ({round(time.time() - start, 2)} seconds") # cache the generated Graph data self.cache[cache_key] = data diff --git a/activity_browser/ui/web/webengine_page.py b/activity_browser/ui/web/webengine_page.py index 64a01ab71..7da8506a2 100644 --- a/activity_browser/ui/web/webengine_page.py +++ b/activity_browser/ui/web/webengine_page.py @@ -1,20 +1,20 @@ """Custom page for debugging javascript code. Without this code, only console.error messages are printed to python output. This code will not tell you the javascript file that the error is in.""" -from logging import getLogger +from loguru import logger from qtpy.QtWebEngineWidgets import QWebEnginePage -log = getLogger(__name__) + class Page(QWebEnginePage): def javaScriptConsoleMessage(self, level: QWebEnginePage.JavaScriptConsoleMessageLevel, message: str, line: str, _: str): if level == QWebEnginePage.InfoMessageLevel: - log.info(f"JS Info (Line {line}): {message}") + logger.info(f"JS Info (Line {line}): {message}") elif level == QWebEnginePage.WarningMessageLevel: - log.warning(f"JS Warning (Line {line}): {message}") + logger.warning(f"JS Warning (Line {line}): {message}") elif level == QWebEnginePage.ErrorMessageLevel: - log.error(f"JS Error (Line {line}): {message}") + logger.error(f"JS Error (Line {line}): {message}") else: - log.debug(f"JS Log (Line {line}): {message}") + logger.debug(f"JS Log (Line {line}): {message}") diff --git a/activity_browser/ui/widgets/central.py b/activity_browser/ui/widgets/central.py index 726cc09c6..f9cf0fa78 100644 --- a/activity_browser/ui/widgets/central.py +++ b/activity_browser/ui/widgets/central.py @@ -1,11 +1,11 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets from activity_browser import signals -log = getLogger(__name__) + class CentralTabWidget(QtWidgets.QTabWidget): diff --git a/activity_browser/ui/widgets/plot.py b/activity_browser/ui/widgets/plot.py index 1e665dc08..9b17ad830 100644 --- a/activity_browser/ui/widgets/plot.py +++ b/activity_browser/ui/widgets/plot.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg from matplotlib.figure import Figure @@ -6,7 +6,7 @@ from activity_browser.utils import savefilepath -log = getLogger(__name__) + class ABPlot(QtWidgets.QWidget): diff --git a/activity_browser/ui/widgets/treeview.py b/activity_browser/ui/widgets/treeview.py index 6b6c6ef06..f6c389514 100644 --- a/activity_browser/ui/widgets/treeview.py +++ b/activity_browser/ui/widgets/treeview.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger import pandas as pd @@ -7,7 +7,7 @@ from .item_model import ABItemModel -log = getLogger(__name__) + class ABTreeView(QtWidgets.QTreeView): @@ -162,7 +162,7 @@ def buildQuery(self) -> str: queries.append(q) query = " & ".join(queries) - log.debug(f"{self.__class__.__name__} built query: {query}") + logger.debug(f"{self.__class__.__name__} built query: {query}") return query @@ -172,7 +172,7 @@ def applyFilter(self): self.model().setQuery(query) self.filtered.emit(True) except Exception as e: - log.info(f"{self.__class__.__name__} {type(e).__name__} in query: {e}") + logger.info(f"{self.__class__.__name__} {type(e).__name__} in query: {e}") self.filtered.emit(False) @staticmethod @@ -217,7 +217,7 @@ def saveState(self) -> dict: def restoreSate(self, state: dict, dataframe: pd.DataFrame): if not self.model(): - log.debug(f"{self.__class__.__name__}: Model must first be set on the treeview before using restoreState") + logger.debug(f"{self.__class__.__name__}: Model must first be set on the treeview before using restoreState") return columns = list(dataframe.columns) diff --git a/activity_browser/ui/wizards/settings_wizard.py b/activity_browser/ui/wizards/settings_wizard.py index bf899f8c2..109a11e88 100644 --- a/activity_browser/ui/wizards/settings_wizard.py +++ b/activity_browser/ui/wizards/settings_wizard.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- import os -from logging import getLogger +from loguru import logger from pathlib import Path from peewee import SqliteDatabase, OperationalError @@ -10,7 +10,7 @@ from activity_browser.settings import ab_settings -log = getLogger(__name__) + class SettingsWizard(QtWidgets.QWizard): @@ -34,7 +34,7 @@ def save_settings(self): if field and field != current_bw_dir: ab_settings.custom_bw_dir = field ab_settings.current_bw_dir = field - log.info(f"Saved startup brightway directory as: {field}") + logger.info(f"Saved startup brightway directory as: {field}") # project field_project = self.field("startup_project") @@ -42,13 +42,13 @@ def save_settings(self): if field_project and field_project != current_startup_project: new_startup_project = field_project ab_settings.startup_project = new_startup_project - log.info(f"Saved startup project as: {new_startup_project}") + logger.info(f"Saved startup project as: {new_startup_project}") ab_settings.write_settings() projects.change_base_directories(Path(field), update=False) def cancel(self): - log.info("Going back to before settings were changed.") + logger.info("Going back to before settings were changed.") if projects._base_data_dir != self.last_bwdir: projects.change_base_directories(Path(self.last_bwdir), update=False) projects.set_current( @@ -266,7 +266,7 @@ def update_project_combo(self, initialization: bool = True, path: str = None): if self.project_names: self.startup_project_combobox.addItems(self.project_names) else: - log.warning("No projects found in this directory.") + logger.warning("No projects found in this directory.") if ab_settings.startup_project in self.project_names: self.startup_project_combobox.setCurrentText(ab_settings.startup_project) else: diff --git a/pyproject.toml b/pyproject.toml index b2bdda0ae..85f3b3796 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ dependencies = [ "qtpy", "salib>=1.4", "seaborn", + "loguru>=0.7", ] From 1b7159b07ffe18c8bc50cbc7450a46949b20493a Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 29 Oct 2025 13:32:15 +0100 Subject: [PATCH 068/267] Remove unused actions --- activity_browser/actions/__init__.py | 4 - .../activity/activity_duplicate_to_loc.py | 276 ------------------ .../actions/activity/activity_graph.py | 20 -- .../actions/activity/activity_modify.py | 2 - .../activity/activity_redo_allocation.py | 35 --- .../database/database_redo_allocation.py | 37 --- 6 files changed, 374 deletions(-) delete mode 100644 activity_browser/actions/activity/activity_duplicate_to_loc.py delete mode 100644 activity_browser/actions/activity/activity_graph.py delete mode 100644 activity_browser/actions/activity/activity_redo_allocation.py delete mode 100644 activity_browser/actions/database/database_redo_allocation.py diff --git a/activity_browser/actions/__init__.py b/activity_browser/actions/__init__.py index 7c842a5e2..236bdc483 100644 --- a/activity_browser/actions/__init__.py +++ b/activity_browser/actions/__init__.py @@ -1,8 +1,6 @@ from .activity.activity_relink import ActivityRelink from .activity.activity_duplicate import ActivityDuplicate from .activity.activity_open import ActivityOpen -from .activity.activity_graph import ActivityGraph -from .activity.activity_duplicate_to_loc import ActivityDuplicateToLoc from .activity.activity_delete import ActivityDelete from .activity.activity_duplicate_to_db import ActivityDuplicateToDB from .activity.activity_modify import ActivityModify @@ -11,7 +9,6 @@ from .activity.activity_open import ActivityOpen from .activity.activity_relink import ActivityRelink from .activity.activity_sdf_to_clipboard import ActivitySDFToClipboard -from .activity.activity_redo_allocation import MultifunctionalProcessRedoAllocation from .activity.process_property_modify import ProcessPropertyModify from .activity.process_property_remove import ProcessPropertyRemove @@ -34,7 +31,6 @@ from .database.database_delete import DatabaseDelete from .database.database_duplicate import DatabaseDuplicate from .database.database_relink import DatabaseRelink -from .database.database_redo_allocation import DatabaseRedoAllocation from .database.database_explorer_open import DatabaseExplorerOpen from .database.database_process import DatabaseProcess from .database.database_import_from_ecoinvent import DatabaseImportFromEcoinvent diff --git a/activity_browser/actions/activity/activity_duplicate_to_loc.py b/activity_browser/actions/activity/activity_duplicate_to_loc.py deleted file mode 100644 index 26209d973..000000000 --- a/activity_browser/actions/activity/activity_duplicate_to_loc.py +++ /dev/null @@ -1,276 +0,0 @@ -import pandas as pd -from qtpy import QtWidgets - -from activity_browser import application, signals -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils import AB_metadata, commontasks -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - -class ActivityDuplicateToLoc(ABAction): - """ - ABAction to duplicate an activity and possibly their exchanges to a new location. - """ - - icon = qicons.copy - text = "Duplicate node to new location" - - @classmethod - @exception_dialogs - def run(cls, activity_key: tuple): - activity = bd.get_activity(activity_key) - db_name = activity["database"] - - # get list of dependent databases for activity and load to MetaDataStore - databases = [] - for exchange in activity.technosphere(): - databases.append(exchange.input[0]) - if db_name not in databases: # add own database if it wasn't added already - databases.append(db_name) - - # load all dependent databases to MetaDataStore - dbs = {db: AB_metadata.get_database_metadata(db) for db in databases} - # get list of all unique locations in the dependent databases (sorted alphabetically) - locations = [] - for db in dbs.values(): - locations += db["location"].to_list() # add all locations to one list - locations = list(set(locations)) # reduce the list to only unique items - locations.sort() - - # get the location to relink - db = dbs[db_name] - old_location = db.loc[db["key"] == activity.key]["location"].iloc[0] - - # trigger dialog with autocomplete-writeable-dropdown-list - options = (old_location, locations) - dialog = LocationLinkingDialog.relink_location( - activity["name"], options, application.main_window - ) - - if dialog.exec_() != LocationLinkingDialog.Accepted: - return - - # read the data from the dialog - for old, new in dialog.relink.items(): - alternatives = [] - new_location = new - if dialog.use_rer.isChecked(): # RER - alternatives.append(dialog.use_rer.text()) - if dialog.use_ews.isChecked(): # Europe without Switzerland - alternatives.append(dialog.use_ews.text()) - if dialog.use_row.isChecked(): # RoW - alternatives.append(dialog.use_row.text()) - # the order we add alternatives is important, they are checked in this order! - if len(alternatives) > 0: - use_alternatives = True - else: - use_alternatives = False - - # successful_links = {} # dict of dicts, key of new exch : {new values} <-- see 'values' below - # in the future, 'alternatives' could be improved by making use of some location hierarchy. From that we could - # get things like if the new location is NL but there is no NL, but RER exists, we use that. However, for that - # we need some hierarchical structure to the location data, which may be available from ecoinvent, but we need - # to look for that. - - # get exchanges that we want to relink - # for exch in activity.technosphere(): - # candidate = self.find_candidate(dbs, exch, old_location, new_location, use_alternatives, alternatives) - # if candidate is None: - # continue # no suitable candidate was found, try the next exchange - # - # # at this point, we have found 1 suitable candidate, whether that is new_location or alternative location - # values = { - # 'amount': exch.get('amount', False), - # 'comment': exch.get('comment', False), - # 'formula': exch.get('formula', False), - # 'uncertainty': exch.get('uncertainty', False) - # } - # successful_links[candidate['key'].iloc[0]] = values - - # now, create a new activity by copying the old one - new_code = commontasks.generate_copy_code(activity.key) - new_act = activity.copy(new_code) - - # update production exchanges - # TODO: check if this is even necessary (I think BW takes care of this) - for exc in new_act.production(): - if exc.input.key == activity.key: - exc.input = new_act - exc.save() - - # update 'products' - for product in new_act.get("products", []): - if product.get("input") == activity.key: - product.input = new_act.key - - # save the new location to the activity - new_act["location"] = new_location - - new_act.save() - - # get exchanges that we want to delete - # del_exch = [] # delete these exchanges - for exch in new_act.technosphere(): - candidate = cls.find_candidate( - db_name, - dbs, - exch, - old_location, - new_location, - use_alternatives, - alternatives, - ) - if candidate is None: - continue # no suitable candidate was found, try the next exchange - exch.input = candidate["key"][0] - exch.save() - # del_exch.append(exch) - # delete exchanges with old locations - # exchange_controller.delete_exchanges(del_exch) - - # add the new exchanges with all values carried over from last exchange - # exchange_controller.add_exchanges(list(successful_links.keys()), new_act.key, successful_links) - - # update the MetaDataStore and open new activity - AB_metadata.update_metadata(new_act.key) - signals.safe_open_activity_tab.emit(new_act.key) - - @staticmethod - def find_candidate( - db_name, dbs, exch, old_location, new_location, use_alternatives, alternatives - ): - """Find a candidate to replace the exchange with.""" - current_db = exch.input[0] - if current_db == db_name: - db = dbs[current_db] - else: # if the exchange is not from the current database, also check the current - # (user may have added their own alternative dependents already) - db = pd.concat([dbs[current_db], dbs[db_name]]) - - if db.loc[db["key"] == exch.input]["location"].iloc[0] != old_location: - return # this exchange has a location we're not trying to re-link - - # get relevant data to match on - row = db.loc[db["key"] == exch.input] - name = row["name"].iloc[0] - prod = row["reference product"].iloc[0] - unit = row["unit"].iloc[0] - - # get candidates to match (must have same name, product and unit) - candidates = db.loc[ - (db["name"] == name) - & (db["reference product"] == prod) - & (db["unit"] == unit) - ] - if len(candidates) <= 1: - return # this activity does not exist in this database with another location (1 is self) - - # check candidates for new_location - candidate = candidates.loc[candidates["location"] == new_location] - if len(candidate) == 0 and not use_alternatives: - return # there is no candidate - elif len(candidate) > 1: - return # there is more than one candidate, we can't know what to use - elif len(candidate) == 0: - # there are no candidates, but we can try alternatives - for alt in alternatives: - candidate = candidates.loc[candidates["location"] == alt] - if len(candidate) == 1: - break # found an alternative in with this alternative location, stop looking - if len(candidate) != 1: - return # there are either no or multiple matches with alternative locations - return candidate - - -class LocationLinkingDialog(QtWidgets.QDialog): - """Display all of the possible location links in a single dialog for the user. - - Allow users to select alternate location links and an option to link to generic alternatives (GLO, RoW). - """ - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Activity Location linking") - - self.loc_label = QtWidgets.QLabel() - self.label_choices = [] - self.grid_box = QtWidgets.QGroupBox("Location link:") - self.grid = QtWidgets.QGridLayout() - self.grid_box.setLayout(self.grid) - - self.use_alternatives_label = QtWidgets.QLabel( - "Use generic alternatives as fallback:" - ) - self.use_alternatives_label.setToolTip( - "If the chosen location is not found, try matching the selected " - "locations below too" - ) - self.use_row = QtWidgets.QCheckBox("RoW") - self.use_row.setChecked(True) - self.use_rer = QtWidgets.QCheckBox("RER") - self.use_ews = QtWidgets.QCheckBox("Europe without Switzerland") - - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.loc_label) - layout.addWidget(self.grid_box) - layout.addWidget(self.use_alternatives_label) - layout.addWidget(self.use_row) - layout.addWidget(self.use_rer) - layout.addWidget(self.use_ews) - layout.addWidget(self.buttons) - self.setLayout(layout) - - @property - def relink(self) -> dict: - """Returns a dictionary of str -> str key/values, showing which keys - should be linked to which values. - - Only returns key/value pairs if they differ. - """ - return { - label.text(): combo.currentText() - for label, combo in self.label_choices - if label.text() != combo.currentText() - } - - @classmethod - def construct_dialog( - cls, - label: str, - options: list, - parent: QtWidgets.QWidget = None, - ) -> "LocationLinkingDialog": - loc, locs = options - - obj = cls(parent) - obj.loc_label.setText(label) - - label = QtWidgets.QLabel(loc) - combo = QtWidgets.QComboBox() - combo.addItems(locs) - combo.setCurrentText(loc) - obj.label_choices.append((label, combo)) - # Start at 1 because row 0 is taken up by the loc_label - obj.grid.addWidget(label, 0, 0, 1, 2) - obj.grid.addWidget(combo, 0, 2, 1, 2) - - obj.updateGeometry() - return obj - - @classmethod - def relink_location( - cls, act_name: str, options: list, parent=None - ) -> "LocationLinkingDialog": - label = "Relinking exchanges from activity '{}' to a new location.".format( - act_name - ) - return cls.construct_dialog(label, options, parent) - - diff --git a/activity_browser/actions/activity/activity_graph.py b/activity_browser/actions/activity/activity_graph.py deleted file mode 100644 index 9c852b211..000000000 --- a/activity_browser/actions/activity/activity_graph.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import List - -from activity_browser import signals -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - -class ActivityGraph(ABAction): - """ - ABAction to open one or multiple activities in the graph explorer - """ - - icon = qicons.graph_explorer - text = "Open in Graph Explorer" - - @staticmethod - @exception_dialogs - def run(activity_keys: List[tuple]): - for key in activity_keys: - signals.open_activity_graph_tab.emit(key) diff --git a/activity_browser/actions/activity/activity_modify.py b/activity_browser/actions/activity/activity_modify.py index e48f8a516..07abb772b 100644 --- a/activity_browser/actions/activity/activity_modify.py +++ b/activity_browser/actions/activity/activity_modify.py @@ -3,8 +3,6 @@ from activity_browser.ui.icons import qicons from activity_browser import bwutils -from .activity_redo_allocation import MultifunctionalProcessRedoAllocation - class ActivityModify(ABAction): """ diff --git a/activity_browser/actions/activity/activity_redo_allocation.py b/activity_browser/actions/activity/activity_redo_allocation.py deleted file mode 100644 index 5f6755e54..000000000 --- a/activity_browser/actions/activity/activity_redo_allocation.py +++ /dev/null @@ -1,35 +0,0 @@ -from qtpy import QtGui -from loguru import logger - -from activity_browser import signals -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd - - - -class MultifunctionalProcessRedoAllocation(ABAction): - """ - ABAction to redo the allocation calculation for a specific process. - """ - - icon = QtGui.QIcon() - text = "Redo allocation for multifunctional process" - tool_tip = "Redo the allocation calculations for this process" - - @staticmethod - @exception_dialogs - def run(node: bd.Node): - if not getattr(node, "multifunctional", None): - return - try: - node.allocate() - - signals.new_statusbar_message.emit(f"Allocation values for process {node} updated.") - except KeyError as exc: - signals.new_statusbar_message.emit("A property for the allocation calculation was not found!") - logger.error(f"A property for the allocation calculation was not found: {node}") - raise exc - except ZeroDivisionError as exc: - signals.new_statusbar_message.emit(str(exc)) - logger.error(f"Zero division in allocation calculation: {exc}") - raise exc diff --git a/activity_browser/actions/database/database_redo_allocation.py b/activity_browser/actions/database/database_redo_allocation.py deleted file mode 100644 index 5fbd0c080..000000000 --- a/activity_browser/actions/database/database_redo_allocation.py +++ /dev/null @@ -1,37 +0,0 @@ -from loguru import logger - -from qtpy import QtGui - -from activity_browser import signals -from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd - - - - -class DatabaseRedoAllocation(ABAction): - """ - ABAction to redo the allocation calculation. - """ - - icon = QtGui.QIcon() - text = "Redo allocation for database" - tool_tip = "Redo the allocation calculations for this database" - - @staticmethod - @exception_dialogs - def run(db_name: str): - if bd.databases[db_name].get("backend") == "multifunctional": - try: - db = bd.Database(db_name) - - for node in filter(lambda x: x.multifunctional, db): - node.allocate() - - signals.new_statusbar_message.emit(f"Allocation values for database {db_name} updated.") - except KeyError as exc: - signals.new_statusbar_message.emit("A property for the allocation calculation was not found!") - logger.error(f"A property for the allocation calculation was not found: {exc}") - except ZeroDivisionError as exc: - signals.new_statusbar_message.emit(str(exc)) - logger.error(f"Zero division in allocation calculation: {exc}") From 1ab73756be2fe16ddeba571cc4feb43860db3150 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 29 Oct 2025 17:58:31 +0100 Subject: [PATCH 069/267] Fix painter resource management by ensuring painter.end() is called after painting in ABFormulaEdit --- activity_browser/ui/widgets/formula_edit.py | 1 + 1 file changed, 1 insertion(+) diff --git a/activity_browser/ui/widgets/formula_edit.py b/activity_browser/ui/widgets/formula_edit.py index 0a1c6e564..7a9efac29 100644 --- a/activity_browser/ui/widgets/formula_edit.py +++ b/activity_browser/ui/widgets/formula_edit.py @@ -333,6 +333,7 @@ def paintEvent(self, event): painter.setPen(Qt.NoPen) painter.fillRect(self.rect(), background_color) self.paint_text(painter) + painter.end() def paint_text(self, painter: QPainter): painter.setFont(self.font()) From 006afa3c9b3157049ee3b198d04064032cccc06a Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 3 Nov 2025 10:54:04 +0100 Subject: [PATCH 070/267] Refactor parameters handling by renaming ParametersPage import and updating uncertainty delegate to support ProjectParametersItem --- .../pages/activity_details/parameters_tab.py | 2 +- .../layouts/pages/parameters/__init__.py | 2 +- .../pages/parameters/parameters_new.py | 664 ++++++++++++++++++ activity_browser/ui/delegates/uncertainty.py | 2 +- 4 files changed, 667 insertions(+), 3 deletions(-) create mode 100644 activity_browser/layouts/pages/parameters/parameters_new.py diff --git a/activity_browser/layouts/pages/activity_details/parameters_tab.py b/activity_browser/layouts/pages/activity_details/parameters_tab.py index 79219bdd7..2a1e14bab 100644 --- a/activity_browser/layouts/pages/activity_details/parameters_tab.py +++ b/activity_browser/layouts/pages/activity_details/parameters_tab.py @@ -1,4 +1,4 @@ -from qtpy import QtWidgets, QtCore, QtGui +from qtpy import QtWidgets, QtCore import pandas as pd import bw2data as bd diff --git a/activity_browser/layouts/pages/parameters/__init__.py b/activity_browser/layouts/pages/parameters/__init__.py index dd02adad4..7a1d89bfd 100644 --- a/activity_browser/layouts/pages/parameters/__init__.py +++ b/activity_browser/layouts/pages/parameters/__init__.py @@ -1,2 +1,2 @@ -from .parameters import ParametersPage +from .parameters_new import ParametersPage diff --git a/activity_browser/layouts/pages/parameters/parameters_new.py b/activity_browser/layouts/pages/parameters/parameters_new.py new file mode 100644 index 000000000..ef4780fc0 --- /dev/null +++ b/activity_browser/layouts/pages/parameters/parameters_new.py @@ -0,0 +1,664 @@ +from qtpy import QtWidgets, QtCore + +import pandas as pd +import bw2data as bd +from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, ParameterizedExchange +from bw2data.backends import ExchangeDataset + +from activity_browser import signals, actions +from activity_browser.ui import widgets, icons, delegates +from activity_browser.bwutils import refresh_parameter, refresh_node, Parameter, database_is_locked, AB_metadata + + +class ParametersPage(QtWidgets.QWidget): + """ + A widget that displays all parameters in the current project. + + This page shows a tree view of parameters organized by scope: + - Project parameters + - Database parameters (grouped by database) + - Activity parameters (grouped by activity group) + + Attributes: + model (ProjectParametersModel): The model containing the data for the parameters. + view (ProjectParametersView): The view displaying the parameters. + """ + + def __init__(self, parent=None): + """ + Initializes the ParametersPage widget. + + Args: + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + + # Parameters tree view + self.model = ProjectParametersModel(self.build_df(), self) + self.view = ProjectParametersView() + self.view.setModel(self.model) + + # Parameterized exchanges table view + self.exchanges_model = ParameterizedExchangesModel(self.build_exchanges_df(), self) + self.exchanges_view = ParameterizedExchangesView() + self.exchanges_view.setModel(self.exchanges_model) + + self.build_layout() + self.connect_signals() + + def build_layout(self): + """ + Builds the layout of the widget. + """ + layout = QtWidgets.QVBoxLayout() + + # Header with title for parameters + header_layout = QtWidgets.QHBoxLayout() + header_label = widgets.ABLabel.demiBold("Parameters") + header_layout.addWidget(header_label) + header_layout.addStretch(1) + + layout.addLayout(header_layout) + layout.addWidget(widgets.ABHLine(self)) + + # Add both views in a splitter + splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical, self) + + # Parameters tree + params_widget = QtWidgets.QWidget() + params_layout = QtWidgets.QVBoxLayout(params_widget) + params_layout.setContentsMargins(0, 0, 0, 0) + params_layout.addWidget(self.view) + splitter.addWidget(params_widget) + + # Parameterized exchanges + exchanges_widget = QtWidgets.QWidget() + exchanges_layout = QtWidgets.QVBoxLayout(exchanges_widget) + exchanges_layout.setContentsMargins(0, 0, 0, 0) + exchanges_label = widgets.ABLabel.demiBold("Parameterized Exchanges") + exchanges_layout.addWidget(exchanges_label) + exchanges_layout.addWidget(self.exchanges_view) + splitter.addWidget(exchanges_widget) + + layout.addWidget(splitter) + self.setLayout(layout) + + def connect_signals(self): + """ + Connects signals to their respective slots. + """ + AB_metadata.synced.connect(self.sync) + signals.parameter.changed.connect(self.sync) + signals.parameter.recalculated.connect(self.sync) + signals.parameter.deleted.connect(self.sync) + signals.project.changed.connect(self.sync) + signals.meta.databases_changed.connect(self.sync) + + def sync(self): + """ + Synchronizes the widget with the current state of parameters. + """ + self.model.setDataFrame(self.build_df()) + self.exchanges_model.setDataFrame(self.build_exchanges_df()) + + self.view.expandAll() + + self.view.resizeColumnToContents(0) + self.view.resizeColumnToContents(2) + self.view.resizeColumnToContents(3) + + def build_df(self) -> pd.DataFrame: + """ + Builds a DataFrame from all parameters in the project. + + Returns: + pd.DataFrame: The DataFrame containing the parameters data. + """ + translated = [] + + # Project parameters + for param in ProjectParameter.select(): + row = self._parameter_to_row(param, "Current project", None) + translated.append(row) + + # Database parameters + for param in DatabaseParameter.select(): + row = self._parameter_to_row(param, f"Database: {param.database}", param.database) + translated.append(row) + + # Activity parameters + for param in ActivityParameter.select(): + row = self._parameter_to_row(param, f"Group: {param.group}", param.database) + translated.append(row) + + columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_database", "_group"] + return pd.DataFrame(translated, columns=columns) + + def _parameter_to_row(self, param, scope_label: str, database: str = None) -> dict: + """ + Converts a parameter to a row dictionary. + + Args: + param: The parameter to convert (ProjectParameter, DatabaseParameter, or ActivityParameter). + scope_label: The label for the scope (e.g., "Current project", "Database: ecoinvent"). + database: The database name (None for project parameters). + + Returns: + dict: A dictionary representing the parameter row. + """ + data = param.dict + + # Create Parameter wrapper + if isinstance(param, ProjectParameter): + parameter = Parameter(param.name, "project", data.get("amount"), data, "project") + group = "project" + elif isinstance(param, DatabaseParameter): + parameter = Parameter(param.name, param.database, data.get("amount"), data, "database") + group = param.database + elif isinstance(param, ActivityParameter): + parameter = Parameter(param.name, param.group, data.get("amount"), data, "activity") + group = param.group + else: + raise ValueError(f"Unknown parameter type: {type(param)}") + + row = { + "name": param.name, + "amount": data.get("amount"), + "uncertainty": data.get("uncertainty type"), + "formula": data.get("formula"), + "comment": data.get("comment"), + "_parameter": parameter, + "_scope": scope_label, + "_database": database, + "_group": group, + } + + return row + + def build_exchanges_df(self) -> pd.DataFrame: + """ + Builds a DataFrame from all parameterized exchanges in the project. + + Returns: + pd.DataFrame: The DataFrame containing the parameterized exchanges data. + """ + translated = [] + + # Get all parameterized exchanges + for param_exc in ParameterizedExchange.select(): + try: + exchange = bd.Edge(document=ExchangeDataset.get_by_id(param_exc.exchange)) + + # Get keys for input and output + input_key = exchange.get("input") + output_key = exchange.get("output") + + # Get metadata from metadata store + input_meta = AB_metadata.get_metadata([input_key], ["name", "unit", "location", "database", "product"]).iloc[0] + output_meta = AB_metadata.get_metadata([output_key], ["name"]).iloc[0] + + row = { + "amount": exchange.get("amount"), + "unit": input_meta.get("unit"), + "from": input_meta.get("product") or input_meta.get("name"), + "to": output_meta.get("name"), + "database": input_meta.get("database"), + "formula": exchange.get("formula"), + "comment": exchange.get("comment"), + "uncertainty": exchange.get("uncertainty type"), + "_exchange": exchange, + "_output_key": output_key, + "_input_key": input_key, + } + translated.append(row) + except Exception as e: + # Skip if exchange can't be loaded + continue + + columns = ["amount", "unit", "from", "to", "database", "formula", "comment", "uncertainty", "_exchange", "_output_key", "_input_key"] + return pd.DataFrame(translated, columns=columns) + + +class ProjectParametersView(widgets.ABTreeView): + """ + A view that displays the project parameters in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + """ + defaultColumnDelegates = { + "amount": delegates.FloatDelegate, + "name": delegates.StringDelegate, + "formula": delegates.NewFormulaDelegate, + "comment": delegates.StringDelegate, + "uncertainty": delegates.UncertaintyDelegate, + } + + class ContextMenu(QtWidgets.QMenu): + """ + A context menu for the ProjectParametersView. + + Attributes: + del_param_action (QAction): The action to delete a parameter. + """ + + def __init__(self, pos, view: "ProjectParametersView"): + """ + Initializes the ContextMenu. + + Args: + pos: The position of the context menu. + view (ProjectParametersView): The view displaying the parameters. + """ + super().__init__(view) + + index = view.indexAt(pos) + if index.isValid() and isinstance(index.internalPointer(), ProjectParametersItem): + item = index.internalPointer() + param = item.parameter.to_peewee_model() + self.del_param_action = actions.ParameterDelete().get_QAction(param) + if not param.is_deletable() or param.name == "dummy_parameter": + self.del_param_action.setEnabled(False) + self.addAction(self.del_param_action) + + +class ProjectParametersItem(widgets.ABDataItem): + """ + An item representing a parameter in the tree view. + """ + + @property + def scoped_parameters(self) -> dict[str, Parameter]: + """ + Returns the parameters in scope of this item's parameter. + + Returns: + dict: The parameters in scope. + """ + from activity_browser.bwutils import parameters_in_scope + return parameters_in_scope(parameter=self["_parameter"]) + + @property + def parameter(self) -> Parameter: + """ + Returns the parameter associated with this item. + + Returns: + Parameter: The current parameter. + """ + return refresh_parameter(self["_parameter"]) + + def flags(self, col: int, key: str): + """ + Returns the item flags for the given column and key. + + Args: + col (int): The column index. + key (str): The key for which to return the flags. + + Returns: + QtCore.Qt.ItemFlags: The item flags. + """ + flags = super().flags(col, key) + + # Allow editing for all parameters except those in locked databases + database = self["_database"] + if database and database_is_locked(database): + return flags + + if key in ["amount", "formula", "uncertainty", "name", "comment"]: + return flags | QtCore.Qt.ItemFlag.ItemIsEditable + return flags + + def setData(self, col: int, key: str, value) -> bool: + """ + Sets the data for the given column and key. + + Args: + col (int): The column index. + key (str): The key for which to set the data. + value: The value to set. + + Returns: + bool: True if the data was set successfully, False otherwise. + """ + if key in ["amount", "formula", "name", "comment"]: + actions.ParameterModify.run(self.parameter, key, value) + + return False + + def decorationData(self, col, key): + """ + Provides decoration data for the item. + + Args: + col: The column index. + key: The key for which to provide decoration data. + + Returns: + The decoration data for the item. + """ + if key not in ["amount"]: + return + + if key == "amount": + if pd.isna(self["formula"]) or self["formula"] is None or self["formula"] == "": + return icons.qicons.empty # empty icon to align the values + return icons.qicons.parameterized + + +class NewProjectParametersItem(widgets.ABDataItem): + """ + An item representing a new parameter placeholder in the tree view. + """ + + def flags(self, col: int, key: str): + """ + Returns the item flags for the given column and key. + + Args: + col (int): The column index. + key (str): The key for which to return the flags. + + Returns: + QtCore.Qt.ItemFlags: The item flags. + """ + flags = super().flags(col, key) + if key == "name": + return flags | QtCore.Qt.ItemFlag.ItemIsEditable + return flags + + def fontData(self, col: int, key: str): + """ + Returns the font data for the given column and key. + + Args: + col (int): The column index. + key (str): The key for which to return the font data. + + Returns: + QtGui.QFont: The font data. + """ + font = super().fontData(col, key) + font.setWeight(font.Weight.ExtraLight) + return font + + def setData(self, col: int, key: str, value) -> bool: + """ + Sets the data for the given column and key. + + Args: + col (int): The column index. + key (str): The key for which to set the data. + value: The value to set. + + Returns: + bool: True if the data was set successfully, False otherwise. + """ + if key != "name" or value == "": + return False + + parameter = Parameter( + name=value, + group=self["_group"], + param_type=self["_param_type"] + ) + + actions.ParameterNewFromParameter.run(parameter) + return True + + +class ProjectParametersModel(widgets.ABItemModel): + """ + A model representing the data for all project parameters. + + Attributes: + dataItemClass (type): The class of the data items. + """ + dataItemClass = ProjectParametersItem + + def __init__(self, dataframe, parent=None): + """ + Initializes the ProjectParametersModel. + + Args: + dataframe (pd.DataFrame): The DataFrame containing the parameters data. + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent, dataframe) + + def createItems(self, dataframe=None) -> list[widgets.ABAbstractItem]: + """ + Creates items from the given DataFrame, organized by scope. + + Args: + dataframe (pd.DataFrame, optional): The DataFrame containing the parameters data. Defaults to None. + + Returns: + list[widgets.ABAbstractItem]: The list of created items. + """ + if dataframe is None: + dataframe = self.dataframe + + items = [] + + # Project parameters + project_branch = self.branchItemClass("Current project") + project_params = dataframe[dataframe._scope == "Current project"] + for index, data in project_params.to_dict(orient="index").items(): + self.dataItemClass(index, data, project_branch) + + # Add "New parameter..." placeholder for project + NewProjectParametersItem(None, { + "name": "New parameter...", + "_group": "project", + "_param_type": "project" + }, project_branch) + + items.append(project_branch) + + # Database parameters - grouped by database + # Get all databases, not just those with parameters + all_databases = set(bd.databases.list) + database_params = dataframe[dataframe._scope.str.startswith("Database: ", na=False)] + databases_with_params = set(database_params._database.unique() if len(database_params) > 0 else []) + + # Combine databases with and without parameters + all_databases_sorted = sorted(all_databases) + + for db_name in all_databases_sorted: + db_branch = self.branchItemClass(f"Database: {db_name}") + + # Add existing parameters for this database + if db_name in databases_with_params: + db_data = database_params[database_params._database == db_name] + for index, data in db_data.to_dict(orient="index").items(): + self.dataItemClass(index, data, db_branch) + + # Add "New parameter..." placeholder if database is not read-only + if not bd.databases[db_name].get("read_only", True): + NewProjectParametersItem(None, { + "name": "New parameter...", + "_group": db_name, + "_param_type": "database" + }, db_branch) + + items.append(db_branch) + + # Activity parameters - grouped by group + activity_params = dataframe[dataframe._scope.str.startswith("Group: ", na=False)] + groups = activity_params._group.unique() if len(activity_params) > 0 else [] + + for group_name in sorted(groups): + group_branch = self.branchItemClass(f"Group: {group_name}") + group_data = activity_params[activity_params._group == group_name] + + for index, data in group_data.to_dict(orient="index").items(): + self.dataItemClass(index, data, group_branch) + + # Add "New parameter..." placeholder if database is not read-only + db_name = group_data.iloc[0]._database if len(group_data) > 0 else None + if db_name and db_name in bd.databases and not bd.databases[db_name].get("read_only", True): + NewProjectParametersItem(None, { + "name": "New parameter...", + "_group": group_name, + "_param_type": "activity" + }, group_branch) + + items.append(group_branch) + + return items + + +class ParameterizedExchangesView(widgets.ABTreeView): + """ + A view that displays parameterized exchanges in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + """ + defaultColumnDelegates = { + "amount": delegates.FloatDelegate, + "unit": delegates.StringDelegate, + "product": delegates.StringDelegate, + "producer": delegates.StringDelegate, + "location": delegates.StringDelegate, + "database": delegates.StringDelegate, + "formula": delegates.NewFormulaDelegate, + "comment": delegates.StringDelegate, + "uncertainty": delegates.UncertaintyDelegate, + } + + class ContextMenu(QtWidgets.QMenu): + """ + A context menu for the ParameterizedExchangesView. + """ + def __init__(self, pos, view: "ParameterizedExchangesView"): + """ + Initializes the ContextMenu. + + Args: + pos: The position of the context menu. + view (ParameterizedExchangesView): The view displaying the exchanges. + """ + super().__init__(view) + + index = view.indexAt(pos) + if index.isValid() and isinstance(index.internalPointer(), ParameterizedExchangesItem): + item = index.internalPointer() + + # Open activity action + open_action = actions.ActivityOpen.get_QAction([item["_output_key"]]) + open_action.setText("Open activity") + self.addAction(open_action) + + +class ParameterizedExchangesItem(widgets.ABDataItem): + """ + An item representing a parameterized exchange in the tree view. + """ + + @property + def exchange(self): + """ + Returns the exchange associated with this item. + + Returns: + The exchange associated with the item. + """ + return self["_exchange"] + + @property + def scoped_parameters(self) -> dict[str, Parameter]: + """ + Returns the parameters in scope of this exchange. + + Returns: + dict: The parameters in scope. + """ + from activity_browser.bwutils import parameters_in_scope + return parameters_in_scope(node=self["_exchange"].output) + + def flags(self, col: int, key: str): + """ + Returns the item flags for the given column and key. + + Args: + col (int): The column index. + key (str): The key for which to return the flags. + + Returns: + QtCore.Qt.ItemFlags: The item flags. + """ + flags = super().flags(col, key) + + # Check if database is locked + if database_is_locked(self.exchange.output["database"]): + return flags + + # Allow editing for specific keys + if key in ["amount", "formula", "comment"]: + return flags | QtCore.Qt.ItemFlag.ItemIsEditable + + return flags + + def setData(self, col: int, key: str, value) -> bool: + """ + Sets the data for the given column and key. + + Args: + col (int): The column index. + key (str): The key for which to set the data. + value: The value to set. + + Returns: + bool: True if the data was set successfully, False otherwise. + """ + if key in ["amount", "formula", "comment"]: + if key == "formula" and not str(value).strip(): + actions.ExchangeFormulaRemove.run([self.exchange]) + return True + + actions.ExchangeModify.run(self.exchange, {key.lower(): value}) + return True + + return False + + def decorationData(self, col, key): + """ + Provides decoration data for the item. + + Args: + col: The column index. + key: The key for which to provide decoration data. + + Returns: + The decoration data for the item. + """ + if key not in ["amount"]: + return + + if key == "amount": + if pd.isna(self["formula"]) or self["formula"] is None or self["formula"] == "": + return icons.qicons.empty # empty icon to align the values + return icons.qicons.parameterized + + +class ParameterizedExchangesModel(widgets.ABItemModel): + """ + A model representing the data for parameterized exchanges. + + Attributes: + dataItemClass (type): The class of the data items. + """ + dataItemClass = ParameterizedExchangesItem + + def __init__(self, dataframe, parent=None): + """ + Initializes the ParameterizedExchangesModel. + + Args: + dataframe (pd.DataFrame): The DataFrame containing the exchanges data. + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent, dataframe) diff --git a/activity_browser/ui/delegates/uncertainty.py b/activity_browser/ui/delegates/uncertainty.py index c978c52eb..7a799c29c 100644 --- a/activity_browser/ui/delegates/uncertainty.py +++ b/activity_browser/ui/delegates/uncertainty.py @@ -34,7 +34,7 @@ def createEditor(self, parent, option, index): item = index.internalPointer() item_name = item.__class__.__name__ - if item_name == "ParametersItem": + if item_name == "ParametersItem" or item_name == "ProjectParametersItem": actions.ParameterUncertaintyModify.run(item["_parameter"].to_peewee_model()) elif item_name == "ExchangesItem": actions.ExchangeUncertaintyModify.run([item.exchange]) From 73fdb46c2ac6bbcb963dc71829c31ca851e0cfca Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 3 Nov 2025 12:27:30 +0100 Subject: [PATCH 071/267] Implement ABTreeModel for hierarchical data representation with lazy loading and filtering capabilities --- activity_browser/ui/core/tree_model.py | 251 +++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 activity_browser/ui/core/tree_model.py diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py new file mode 100644 index 000000000..9b3f7b3af --- /dev/null +++ b/activity_browser/ui/core/tree_model.py @@ -0,0 +1,251 @@ +from typing import Optional + +import pandas as pd +from PySide6.QtCore import QModelIndex, Qt +from PySide6.QtWidgets import QWidget +from PySide6.QtCore import QAbstractItemModel + + +class ABTreeModel(QAbstractItemModel): + def __init__(self, df: pd.DataFrame, parent: Optional[QWidget] = None, chunk_size: int = -1) -> None: + super().__init__(parent) + self.df = df + self.df_query: str = "index == index" # default query that matches all rows + self.children_map = self.build_hierarchy_from_index(df.index) + self.lazy = chunk_size > 0 + self.chunk_size = chunk_size + + # Track how many children are currently loaded for each parent + self.loaded_counts: dict[tuple, int] = {} + if self.lazy: + # Initially load first chunk for each parent + for parent_path in self.children_map: + total = len(self.children_map[parent_path]) + self.loaded_counts[parent_path] = min(chunk_size, total) + else: + # All rows are loaded + for parent_path, children in self.children_map.items(): + self.loaded_counts[parent_path] = len(children) + + + # --- required model overrides --- + def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex: + if not self.hasIndex(row, column, parent): + return QModelIndex() + + parent_path = parent.internalPointer() or tuple() + all_children = self.children_map.get(parent_path, []) + + if not 0 <= row < len(all_children): + return QModelIndex() + + if parent_path != tuple(): + pass + + # children_map now stores full child paths; use directly + child_path = all_children[row] + + return self.createIndex(row, column, child_path) + + + def parent(self, index: QModelIndex) -> QModelIndex: + if not index.isValid(): + return QModelIndex() + + # Full path for the current index + path = index.internalPointer() + parent_path = path[:-1] + + if len(parent_path) == 0: + return QModelIndex() + + grandparent_path = parent_path[:-1] + grandparent_children = self.children_map.get(grandparent_path, []) + # children_map stores full paths; find the parent's row among its siblings + row = grandparent_children.index(parent_path) + + return self.createIndex(row, 0, grandparent_children[row]) + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + # For tree models, when the parent is valid and column > 0, return 0 + if parent.isValid() and parent.column() != 0: + return 0 + + parent_path = parent.internalPointer() or tuple() + + # Return the number of currently loaded children + return self.loaded_counts.get(parent_path, 0) + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802 (Qt signature) + # Always return the full column count for consistent tree structure + return len(self.df.columns) + 1 # +1 for tree column + + def data(self, index: QModelIndex, role: int = Qt.DisplayRole): + if not index.isValid() or self.df.empty: + return None + + if role in (Qt.DisplayRole, Qt.EditRole, Qt.UserRole): + path = index.internalPointer() + + if index.column() == 0: + return path[-1] # last element in the path + + # Only show data columns for leaf nodes (full depth paths) + if len(path) < self.df.index.nlevels: + return None # intermediate nodes have no data in non-tree columns + + col_name = self.headerData(index.column()) + val = self.df.at[path, col_name] + return val + + return None + + def headerData(self, section: int, orientation: Qt.Orientation = Qt.Horizontal, role: int = Qt.DisplayRole): + if orientation == Qt.Vertical or not role == Qt.DisplayRole: + return None + + if section == 0: + return "Hierarchy" + + return self.df.columns[section - 1] + + def canFetchMore(self, parent: QModelIndex) -> bool: + """Check if this parent has more children that can be loaded.""" + if not self.lazy: + return False + + parent_path = parent.internalPointer() or tuple() + + # Can fetch more if we have more children than currently loaded + total_children = len(self.children_map.get(parent_path, [])) + loaded = self.loaded_counts.get(parent_path, 0) + + return loaded < total_children + + def fetchMore(self, parent: QModelIndex) -> None: + """Load the next chunk of children when user scrolls.""" + if not self.lazy: + return + + parent_path = parent.internalPointer() or tuple() + + total_children = len(self.children_map.get(parent_path, [])) + currently_loaded = self.loaded_counts.get(parent_path, 0) + + if currently_loaded >= total_children: + return # Everything already loaded + + # Calculate how many more to load + remaining = total_children - currently_loaded + to_load = min(self.chunk_size, remaining) + + # Notify view that we're about to add rows + first_new_row = currently_loaded + last_new_row = currently_loaded + to_load - 1 + + self.beginInsertRows(parent, first_new_row, last_new_row) + self.loaded_counts[parent_path] = currently_loaded + to_load + self.endInsertRows() + + # --- helper functions --- + def build_hierarchy_from_index(self, pandas_index: pd.Index) -> dict[tuple, list[tuple]]: + from collections import defaultdict + children_map = defaultdict(list) + + # Convert index to frame once for all operations + idx_df = pandas_index.to_frame(index=False) + + + # Process each level + for level in range(idx_df.shape[1]): + # Get unique child paths at this level (as tuples) + child_paths = idx_df.iloc[:, :level + 1].drop_duplicates() + child_tuples = list(child_paths.itertuples(index=False, name=None)) + + if level == 0: + # Root level - all children belong to empty tuple parent + children_map[tuple()] = child_tuples + else: + # Group children by their parent path + parent_paths = child_paths.iloc[:, :level] + parent_tuples = list(parent_paths.itertuples(index=False, name=None)) + + # Build parent->children mapping efficiently with zip + for parent, child in zip(parent_tuples, child_tuples): + children_map[parent].append(child) + + return dict(children_map) + + def reset_hierarchy(self, df: pd.DataFrame = None) -> None: + df = df if df is not None else self.df + + self.layoutAboutToBeChanged.emit() + + old_persistent_paths = [idx.internalPointer() for idx in self.persistentIndexList()] + + self.children_map = self.build_hierarchy_from_index(df.index) + + # Reset loaded counts for lazy loading + self.loaded_counts = {} + if self.lazy: + # Load first chunk for each parent + for parent_path in self.children_map: + total = len(self.children_map[parent_path]) + self.loaded_counts[parent_path] = min(self.chunk_size, total) + else: + # All rows loaded + for parent_path, children in self.children_map.items(): + self.loaded_counts[parent_path] = len(children) + + new_persistent = [] + for path, index in zip(old_persistent_paths, self.persistentIndexList()): + parent_path = path[:-1] + if parent_path in self.children_map and path in self.children_map[parent_path]: + row = self.children_map[parent_path].index(path) + new_index = self.createIndex(row, index.column(), self.children_map[parent_path][row]) + new_persistent.append(new_index) + else: + new_persistent.append(QModelIndex()) + + # Update the model's persistent indexes + self.changePersistentIndexList(self.persistentIndexList(), new_persistent) + + self.layoutChanged.emit() + + + def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: + # Extract the unique order of higher levels + column_name = self.headerData(column) if column > 0 else self.df.index.names[-1] + higher_levels = self.df.index.droplevel(-1).unique() + + # Build a new index by sorting only within each higher level + sorted_index = [] + + for lvl in higher_levels: + mask = self.df.index.droplevel(-1) == lvl + partial_df = self.df.loc[mask].sort_values(by=column_name, ascending=(order == Qt.SortOrder.AscendingOrder)) + sorted_index.append(partial_df.index) + + sorted_index = sorted_index[0].append(sorted_index[1:]) # Flatten + self.df = self.df.loc[sorted_index] # Update dataframe to new sorted order + self.filter() + + def filter(self, pandas_query: str = None) -> None: + """Filter the DataFrame based on a simple substring match across all columns.""" + if pandas_query is None: + pandas_query = self.df_query + filtered_df = self.df.query(pandas_query) + self.df_query = pandas_query + self.reset_hierarchy(filtered_df) + + def quick_filter(self, substring: str) -> None: + """Quick filter rows containing the substring in any column.""" + if not substring: + self.filter("index == index") # reset filter + return + + query = " or ".join( + f"`{col}`.astype('string').str.contains({substring!r}, case=False, na=False, regex=False)" + for col in self.df.columns + ) + self.filter(query) \ No newline at end of file From c42432c95346b573e80bae84601915d949f88527 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 3 Nov 2025 22:53:12 +0100 Subject: [PATCH 072/267] Enhance DatabaseProductsPane and ABTreeModel with improved data handling and filtering capabilities. Introduce ABNewTreeView for enhanced tree view functionality, including context menus and column filtering. --- .../layouts/panes/database_products.py | 136 ++++-------- activity_browser/ui/core/__init__.py | 1 + activity_browser/ui/core/tree_model.py | 136 +++++++++++- activity_browser/ui/widgets/__init__.py | 1 + activity_browser/ui/widgets/tree_view.py | 202 ++++++++++++++++++ 5 files changed, 370 insertions(+), 106 deletions(-) create mode 100644 activity_browser/ui/widgets/tree_view.py diff --git a/activity_browser/layouts/panes/database_products.py b/activity_browser/layouts/panes/database_products.py index 36256c434..86fa67c7a 100644 --- a/activity_browser/layouts/panes/database_products.py +++ b/activity_browser/layouts/panes/database_products.py @@ -9,16 +9,10 @@ from activity_browser import actions, ui, signals, application from activity_browser.settings import project_settings -from activity_browser.ui import core, widgets, delegates +from activity_browser.ui import core, widgets, delegates, icons from activity_browser.bwutils import AB_metadata, database_is_locked, database_is_legacy - -DEFAULT_STATE = { - "columns": ["activity", "product", "Type", "Unit", "Location"], - "visible_columns": ["activity", "product", "type", "unit", "location"], -} - NODETYPES = { "all_nodes": [], "processes": ["process", "multifunctional", "processwithreferenceproduct", "nonfunctional"], @@ -50,7 +44,7 @@ def __init__(self, parent, db_name: str): super().__init__(parent) self.database = bd.Database(db_name) self.title = db_name - self.model = ProductModel(self) + self.model = ProductModel(parent=self) # Create the QTableView and set the model self.table_view = ProductView(self, db_name=db_name) @@ -139,7 +133,8 @@ def sync(self): """ t = time() df = self.build_df() - self.model.setDataFrame(df) + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) for col in df.columns: index = self.model.columns().index(col) if df[col].isna().all(): @@ -207,23 +202,6 @@ def event(self, event): return super().event(event) - def save_state_to_settings(self): - """ - Saves the state of the table view to the project settings. - """ - project_settings.settings["database_explorer"] = project_settings.settings.get("database_explorer", {}) - project_settings.settings["database_explorer"][self.database.name] = self.table_view.saveState() - project_settings.write_settings() - - def get_state_from_settings(self): - """ - Gets the state from the project settings. - - Returns: - dict: The state of the table view. - """ - return DEFAULT_STATE - def search_error(self, reset=False): """ Handles the search error by changing the search bar color. @@ -240,7 +218,7 @@ def search_error(self, reset=False): self.search.setPalette(palette) -class ProductView(ui.widgets.ABTreeView): +class ProductView(ui.widgets.ABNewTreeView): """ A view that displays the products in a tree structure. @@ -259,9 +237,6 @@ class ContextMenu(ui.widgets.ABMenu): text="Open process" if len(p.selected_activities) == 1 else "Open processes", enable=len(p.selected_activities) > 0 ), - lambda m, p: m.add(actions.ActivityGraph, p.selected_activities, - enable=len(p.selected_activities) > 0, - ), lambda m: m.addSeparator(), lambda m, p: m.add(actions.ActivityNewProcess, p.db_name, enable=not database_is_locked(p.db_name), @@ -353,8 +328,10 @@ def selected_products(self) -> list[tuple]: Returns: list[tuple]: The list of selected products. """ - items = [i.internalPointer() for i in self.selectedIndexes() if isinstance(i.internalPointer(), ProductItem)] - return list({item["key"] for item in items if not item["type"] == "nonfunctional"}) + keys = self.model().values_from_indices("key", self.selectedIndexes()) + types = self.model().values_from_indices("type", self.selectedIndexes()) + + return list({key for key, type in zip(keys, types) if not type == "nonfunctional"}) @property def selected_activities(self) -> list[tuple]: @@ -364,66 +341,38 @@ def selected_activities(self) -> list[tuple]: Returns: list[tuple]: The list of selected activities. """ - items = [i.internalPointer() for i in self.selectedIndexes() if isinstance(i.internalPointer(), ProductItem)] - return list({item["processor"] if not pd.isna(item["processor"]) else item["key"] for item in items}) - - -class ProductItem(ui.widgets.ABDataItem): - """ - An item representing a product in the tree view. - """ - def decorationData(self, col, key): - """ - Provides decoration data for the item. + processors = self.model().values_from_indices("processor", self.selectedIndexes()) + keys = self.model().values_from_indices("key", self.selectedIndexes()) - Args: - col: The column index. - key: The key for which to provide decoration data. + return list({processor if not pd.isna(processor) else key for processor, key in zip(processors, keys)}) - Returns: - The decoration data for the item. - """ - if key == "name" and self["name"]: - if self["type"] == "processwithreferenceproduct": - return ui.icons.qicons.processproduct - if self["type"] in NODETYPES["biosphere"]: - return ui.icons.qicons.biosphere - return ui.icons.qicons.process - if key == "product": - if self["type"] in ["product", "processwithreferenceproduct"]: - return ui.icons.qicons.product - elif self["type"] == "waste": - return ui.icons.qicons.waste - - def flags(self, col: int, key: str): - """ - Returns the item flags for the given column and key. - Args: - col (int): The column index. - key (str): The key for which to return the flags. - - Returns: - QtCore.Qt.ItemFlags: The item flags. - """ - return super().flags(col, key) | Qt.ItemFlag.ItemIsDragEnabled +class ProductModel(ui.core.ABTreeModel): + #-- flag overrides --- + def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: + return True + + #-- data overrides --- + def decorationData(self, index: QtCore.QModelIndex) -> any: + column_name = self.column_name(index) + row = self.row(index) - def displayData(self, col: int, key: str): - if key.startswith("property_") and not pd.isna(self[key]) and self[key]["normalize"]: - prop = self[key].copy() - prop["unit"] = prop['unit'] + f" / {self['unit']}" - return prop - return super().displayData(col, key) + if row is None: + return None - -class ProductModel(ui.widgets.ABItemModel): - """ - A model representing the data for the products. - - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = ProductItem + node_type = row.get("type", "").lower() + + if column_name not in ["name", "product"]: + return None + if column_name == "product" and node_type in ["product", "processwithreferenceproduct"]: + return icons.qicons.product + if column_name == "product" and node_type == "waste": + return icons.qicons.waste + if node_type == "processwithreferenceproduct": + return icons.qicons.processproduct + if node_type in NODETYPES["biosphere"]: + return icons.qicons.biosphere + return icons.qicons.process def mimeData(self, indices: list[QtCore.QModelIndex]): """ @@ -442,8 +391,7 @@ def mimeData(self, indices: list[QtCore.QModelIndex]): data.setPickleData("application/bw-nodekeylist", list(keys)) return data - @staticmethod - def values_from_indices(key: str, indices: list[QtCore.QModelIndex]): + def values_from_indices(self, key: str, indices: list[QtCore.QModelIndex]): """ Returns the values from the given indices. @@ -454,10 +402,6 @@ def values_from_indices(key: str, indices: list[QtCore.QModelIndex]): Returns: list: The list of values. """ - values = [] - for index in indices: - item = index.internalPointer() - if not item or item[key] is None: - continue - values.append(item[key]) - return values + column = self.df.columns.get_loc(key) + return [index.data(Qt.ItemDataRole.DisplayRole) for index in indices if index.column() == column] + diff --git a/activity_browser/ui/core/__init__.py b/activity_browser/ui/core/__init__.py index 47cb5c6c7..28570446f 100644 --- a/activity_browser/ui/core/__init__.py +++ b/activity_browser/ui/core/__init__.py @@ -1 +1,2 @@ from .mimedata import ABMimeData +from .tree_model import ABTreeModel diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 9b3f7b3af..b5b5742b2 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -1,5 +1,6 @@ from typing import Optional +from loguru import logger import pandas as pd from PySide6.QtCore import QModelIndex, Qt from PySide6.QtWidgets import QWidget @@ -7,11 +8,11 @@ class ABTreeModel(QAbstractItemModel): - def __init__(self, df: pd.DataFrame, parent: Optional[QWidget] = None, chunk_size: int = -1) -> None: + def __init__(self, df: pd.DataFrame = None, parent: Optional[QWidget] = None, chunk_size: int = -1) -> None: super().__init__(parent) - self.df = df + self.df = df if df is not None else pd.DataFrame() self.df_query: str = "index == index" # default query that matches all rows - self.children_map = self.build_hierarchy_from_index(df.index) + self.children_map = self.build_hierarchy_from_index(self.df.index) self.lazy = chunk_size > 0 self.chunk_size = chunk_size @@ -26,7 +27,27 @@ def __init__(self, df: pd.DataFrame, parent: Optional[QWidget] = None, chunk_siz # All rows are loaded for parent_path, children in self.children_map.items(): self.loaded_counts[parent_path] = len(children) + + def columns(self) -> list[str]: + """Return the list of column names, including the tree column.""" + return ["index"] + list(self.df.columns) + def column_name(self, index: QModelIndex) -> str: + """Return the name of the column at the given index, including the tree column.""" + return self.columns()[index.column()] + + def row(self, index: QModelIndex) -> pd.Series | None: + """Return the DataFrame row corresponding to the given index, or None for non-leaf nodes.""" + if not index.isValid(): + return None + + path = index.internalPointer() + + # Only return data for leaf nodes (full depth paths) + if len(path) < self.df.index.nlevels: + return None + + return self.df.loc[path] # --- required model overrides --- def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex: @@ -80,11 +101,25 @@ def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802 # Always return the full column count for consistent tree structure return len(self.df.columns) + 1 # +1 for tree column + #--- data overrides --- def data(self, index: QModelIndex, role: int = Qt.DisplayRole): if not index.isValid() or self.df.empty: return None - if role in (Qt.DisplayRole, Qt.EditRole, Qt.UserRole): + if role == Qt.DisplayRole: + return self.displayData(index) + elif role == Qt.EditRole: + return self.editData(index) + elif role == Qt.UserRole: + return self.userData(index) + elif role == Qt.DecorationRole: + return self.decorationData(index) + elif role == Qt.FontRole: + return self.fontData(index) + + return None + + def displayData(self, index: QModelIndex) -> any: path = index.internalPointer() if index.column() == 0: @@ -95,17 +130,63 @@ def data(self, index: QModelIndex, role: int = Qt.DisplayRole): return None # intermediate nodes have no data in non-tree columns col_name = self.headerData(index.column()) - val = self.df.at[path, col_name] + val = self.df.at[path[0] if len(path) == 1 else path, col_name] return val + def editData(self, index: QModelIndex) -> any: + return self.displayData(index) + + def userData(self, index: QModelIndex) -> any: + return self.displayData(index) + + def decorationData(self, index: QModelIndex) -> any: return None + + def fontData(self, index: QModelIndex) -> any: + return None + + #--- flag overrides --- + def flags(self, index): + flags = Qt.ItemFlag.NoItemFlags + if self.indexEnabled(index): + flags |= Qt.ItemFlag.ItemIsEnabled + if self.indexSelectable(index): + flags |= Qt.ItemFlag.ItemIsSelectable + if self.indexEditable(index): + flags |= Qt.ItemFlag.ItemIsEditable + if self.indexDragEnabled(index): + flags |= Qt.ItemFlag.ItemIsDragEnabled + if self.indexDropEnabled(index): + flags |= Qt.ItemFlag.ItemIsDropEnabled + if self.indexUserCheckable(index): + flags |= Qt.ItemFlag.ItemIsUserCheckable + return flags + + def indexEnabled(self, index: QModelIndex) -> bool: + return True + + def indexSelectable(self, index: QModelIndex) -> bool: + return True + + def indexEditable(self, index: QModelIndex) -> bool: + return False + + def indexDragEnabled(self, index: QModelIndex) -> bool: + return False + + def indexDropEnabled(self, index: QModelIndex) -> bool: + return False + + def indexUserCheckable(self, index: QModelIndex) -> bool: + return False + def headerData(self, section: int, orientation: Qt.Orientation = Qt.Horizontal, role: int = Qt.DisplayRole): if orientation == Qt.Vertical or not role == Qt.DisplayRole: return None if section == 0: - return "Hierarchy" + return "index" return self.df.columns[section - 1] @@ -216,14 +297,18 @@ def reset_hierarchy(self, df: pd.DataFrame = None) -> None: def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: # Extract the unique order of higher levels column_name = self.headerData(column) if column > 0 else self.df.index.names[-1] - higher_levels = self.df.index.droplevel(-1).unique() + higher_levels = self.df.index.droplevel(-1).unique() if self.df.index.nlevels > 1 else [None] # Build a new index by sorting only within each higher level sorted_index = [] for lvl in higher_levels: - mask = self.df.index.droplevel(-1) == lvl - partial_df = self.df.loc[mask].sort_values(by=column_name, ascending=(order == Qt.SortOrder.AscendingOrder)) + mask = self.df.index.droplevel(-1) == lvl if lvl is not None else self.df.index + partial_df = self.df.loc[mask] + if column_name is not None: + partial_df.sort_values(by=column_name, ascending=(order == Qt.SortOrder.AscendingOrder), inplace=True) + else: + partial_df = partial_df.sort_index(ascending=(order == Qt.SortOrder.AscendingOrder)) sorted_index.append(partial_df.index) sorted_index = sorted_index[0].append(sorted_index[1:]) # Flatten @@ -248,4 +333,35 @@ def quick_filter(self, substring: str) -> None: f"`{col}`.astype('string').str.contains({substring!r}, case=False, na=False, regex=False)" for col in self.df.columns ) - self.filter(query) \ No newline at end of file + self.filter(query) + + def set_dataframe(self, df: pd.DataFrame) -> None: + self.beginResetModel() + self.df = df + if not all(self.df.index.names): + logger.warning("DataFrame index has unnamed levels; resetting to default integer index.") + self.df.reset_index(drop=True, inplace=True) + self.df.index.name = "index" + + self.df.index.names = [name + "_i" for name in self.df.index.names] # append _i to index level names to avoid conflicts + + self.reset_hierarchy() + self.endResetModel() + + def group(self, columns: list[str]) -> None: + """Regroup the DataFrame by the specified columns.""" + # Set the new index with specified columns + current_index_names = self.df.index.names + new_index_names = columns + current_index_names + df = self.df.reset_index() + new_index = pd.MultiIndex.from_frame(df[new_index_names]) + new_index.names = [i+"_i" if not i.endswith("_i") else i for i in new_index.names] + + self.df.set_index(new_index, inplace=True) + + self.reset_hierarchy() + + def ungroup(self) -> None: + """Ungroup the DataFrame by resetting the index.""" + self.df.reset_index(drop=True, inplace=True) + self.reset_hierarchy() diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index f9e86f4b0..6072f2f47 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -21,3 +21,4 @@ from .menu import ABMenu from .drop_overlay import ABDropOverlay from .plot import ABPlot +from .tree_view import ABNewTreeView diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py new file mode 100644 index 000000000..45db4d4c2 --- /dev/null +++ b/activity_browser/ui/widgets/tree_view.py @@ -0,0 +1,202 @@ +from loguru import logger + +import pandas as pd + +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt + +from .item_model import ABItemModel + + + + +class ABNewTreeView(QtWidgets.QTreeView): + # fired when the filter is applied, fires False when an exception happens during querying + filtered: QtCore.SignalInstance = QtCore.Signal(bool) + + defaultColumnDelegates = {} + + class HeaderMenu(QtWidgets.QMenu): + def __init__(self, pos: QtCore.QPoint, view: "ABNewTreeView"): + super().__init__(view) + + model = view.model() + + col_index = view.columnAt(pos.x()) + col_name = model.columns()[col_index] + + search_box = QtWidgets.QLineEdit(self) + search_box.setText(view.columnFilters.get(col_name, "")) + search_box.setPlaceholderText("Search") + search_box.selectAll() + search_box.textChanged.connect(lambda query: view.setColumnFilter(col_name, query)) + widget_action = QtWidgets.QWidgetAction(self) + widget_action.setDefaultWidget(search_box) + self.addAction(widget_action) + + self.addAction(QtGui.QIcon(), "Group by column", lambda: model.group([col_name])) + self.addAction(QtGui.QIcon(), "Ungroup", model.ungroup) + self.addAction(QtGui.QIcon(), "Clear column filter", lambda: view.setColumnFilter(col_name, "")) + self.addAction(QtGui.QIcon(), "Clear all filters", + lambda: [view.setColumnFilter(name, "") for name in list(view.columnFilters.keys())], + ) + self.addSeparator() + + def toggle_slot(action: QtWidgets.QAction): + index = action.data() + hidden = view.isColumnHidden(index) + view.setColumnHidden(index, not hidden) + + view_menu = QtWidgets.QMenu(view) + view_menu.setTitle("View") + self.view_actions = [] + + for i in range(1, len(model.columns())): + action = QtWidgets.QAction(model.columns()[i]) + action.setCheckable(True) + action.setChecked(not view.isColumnHidden(i)) + action.setData(i) + view_menu.addAction(action) + self.view_actions.append(action) + + view_menu.triggered.connect(toggle_slot) + + self.addMenu(view_menu) + + search_box.setFocus() + + class ContextMenu(QtWidgets.QMenu): + def __init__(self, pos, view): + super().__init__(view) + + def __init__(self, parent=None): + super().__init__(parent) + + self.setUniformRowHeights(True) + + self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.showContextMenu) + + self.setSelectionBehavior(QtWidgets.QTreeView.SelectionBehavior.SelectRows) + self.setSelectionMode(QtWidgets.QTreeView.SelectionMode.ExtendedSelection) + + header = self.header() + header.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + header.customContextMenuRequested.connect(self.showHeaderMenu) + + self.columnFilters: dict[str, str] = {} # dict[column_name, query] for filtering the dataframe + self.allFilter: str = "" # filter applied to the entire dataframe + + def setModel(self, model): + super().setModel(model) + + model.modelAboutToBeReset.connect(self.clearColumnDelegates) + model.modelReset.connect(self.setDefaultColumnDelegates) + model.modelReset.connect(self.updateIndexColumnVisibility) + + self.setDefaultColumnDelegates() + self.updateIndexColumnVisibility() + + def model(self) -> ABItemModel: + return super().model() + + # === Functionality related to contextmenus + + def showContextMenu(self, pos): + self.ContextMenu(pos, self).exec_(self.mapToGlobal(pos)) + + def showHeaderMenu(self, pos): + self.HeaderMenu(pos, self).exec_(self.mapToGlobal(pos)) + + def setColumnFilter(self, column_name: str, query: str): + """ + Set a filter for a specific column using a string query. If the query is empty remove the filter from the column + """ + col_index = self.model().columns().index(column_name) + + if query: + self.columnFilters[column_name] = query + self.model().filtered_columns.add(col_index) + elif column_name in self.columnFilters: + del self.columnFilters[column_name] + self.model().filtered_columns.discard(col_index) + + self.applyFilter() + + # === Functionality related to filtering + + def setAllFilter(self, query: str): + self.allFilter = query + self.applyFilter() + + def buildQuery(self) -> str: + queries = ["(index == index)"] + + # query for the column filters + for col in list(self.columnFilters): + if col not in self.model().columns(): + del self.columnFilters[col] + + for col, query in self.columnFilters.items(): + q = f"({col}.astype('str').str.contains('{self.format_query(query)}'))" + queries.append(q) + + # query for the all filter + if self.allFilter.startswith('='): + queries.append(f"({self.allFilter[1:]})") + else: + all_queries = [] + formatted_filter = self.format_query(self.allFilter) + + for i, col in enumerate(self.model().columns()): + if self.isColumnHidden(i) and i not in self.model().grouped_columns: + continue + all_queries.append(f"(`{col}`.astype('str').str.contains('{formatted_filter}', False))") + + q = f"({' | '.join(all_queries)})" + queries.append(q) + + query = " & ".join(queries) + logger.debug(f"{self.__class__.__name__} built query: {query}") + + return query + + def applyFilter(self): + query = self.buildQuery() + try: + self.model().setQuery(query) + self.filtered.emit(True) + except Exception as e: + logger.info(f"{self.__class__.__name__} {type(e).__name__} in query: {e}") + self.filtered.emit(False) + + @staticmethod + def format_query(query: str) -> str: + return query.translate(str.maketrans({'(': '\\(', ')': '\\)', "'": "\\'"})) + + # === Functionality related to setting the column delegates + def clearColumnDelegates(self): + for i in range(self.model().columnCount()): + self.setItemDelegateForColumn(i, None) + + def setDefaultColumnDelegates(self): + columns = self.model().columns() + for i, col_name in enumerate(columns): + if col_name in self.defaultColumnDelegates: + delegate = self.defaultColumnDelegates[col_name](self) + self.setItemDelegateForColumn(i, delegate) + elif col_name.startswith("property_"): + self.setItemDelegateForColumn(i, self.propertyDelegate) + + def updateIndexColumnVisibility(self): + """Hide the index column (column 0) if the dataframe index is only one level deep.""" + model = self.model() + if model is None: + return + + # Check if model has the df attribute (ABTreeModel style) + if hasattr(model, 'df') and hasattr(model.df, 'index'): + # Hide index column if it's only one level deep + hide_index = model.df.index.nlevels == 1 + self.setColumnHidden(0, hide_index) + From 0d7d67979291ea0c948b4cf998c58dbe5a32ac96 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 3 Nov 2025 23:24:43 +0100 Subject: [PATCH 073/267] Refactor ABTreeModel to use a dictionary for query management and update filtering method. Modify ABNewTreeView to connect layout changes for index column visibility updates. --- activity_browser/ui/core/tree_model.py | 12 +++++++----- activity_browser/ui/widgets/tree_view.py | 6 +++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index b5b5742b2..55011269a 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -11,7 +11,7 @@ class ABTreeModel(QAbstractItemModel): def __init__(self, df: pd.DataFrame = None, parent: Optional[QWidget] = None, chunk_size: int = -1) -> None: super().__init__(parent) self.df = df if df is not None else pd.DataFrame() - self.df_query: str = "index == index" # default query that matches all rows + self.df_query: dict[str, str] = {"model": "index == index"} # dictionary where queries can be registered self.children_map = self.build_hierarchy_from_index(self.df.index) self.lazy = chunk_size > 0 self.chunk_size = chunk_size @@ -315,12 +315,14 @@ def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) - self.df = self.df.loc[sorted_index] # Update dataframe to new sorted order self.filter() - def filter(self, pandas_query: str = None) -> None: + def filter(self, key: str = None, query: str = None) -> None: """Filter the DataFrame based on a simple substring match across all columns.""" - if pandas_query is None: - pandas_query = self.df_query + if query is not None and key is not None: + self.df_query[key] = query + + pandas_query = " & ".join(self.df_query.values()) filtered_df = self.df.query(pandas_query) - self.df_query = pandas_query + self.reset_hierarchy(filtered_df) def quick_filter(self, substring: str) -> None: diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py index 45db4d4c2..1bbdf682e 100644 --- a/activity_browser/ui/widgets/tree_view.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -92,7 +92,7 @@ def setModel(self, model): model.modelAboutToBeReset.connect(self.clearColumnDelegates) model.modelReset.connect(self.setDefaultColumnDelegates) - model.modelReset.connect(self.updateIndexColumnVisibility) + model.layoutChanged.connect(self.updateIndexColumnVisibility) self.setDefaultColumnDelegates() self.updateIndexColumnVisibility() @@ -149,7 +149,7 @@ def buildQuery(self) -> str: formatted_filter = self.format_query(self.allFilter) for i, col in enumerate(self.model().columns()): - if self.isColumnHidden(i) and i not in self.model().grouped_columns: + if self.isColumnHidden(i): continue all_queries.append(f"(`{col}`.astype('str').str.contains('{formatted_filter}', False))") @@ -164,7 +164,7 @@ def buildQuery(self) -> str: def applyFilter(self): query = self.buildQuery() try: - self.model().setQuery(query) + self.model().filter("ABNewTreeView", query) self.filtered.emit(True) except Exception as e: logger.info(f"{self.__class__.__name__} {type(e).__name__} in query: {e}") From 7b6458066053b4625b2e4c57141c46550b802937 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 3 Nov 2025 23:28:54 +0100 Subject: [PATCH 074/267] Comment out filtered_columns updates in setColumnFilter method to prevent unintended modifications during filtering. --- activity_browser/ui/widgets/tree_view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py index 1bbdf682e..1dbc99463 100644 --- a/activity_browser/ui/widgets/tree_view.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -116,10 +116,10 @@ def setColumnFilter(self, column_name: str, query: str): if query: self.columnFilters[column_name] = query - self.model().filtered_columns.add(col_index) + # self.model().filtered_columns.add(col_index) elif column_name in self.columnFilters: del self.columnFilters[column_name] - self.model().filtered_columns.discard(col_index) + # self.model().filtered_columns.discard(col_index) self.applyFilter() From 5db45101356f36acddf6b7c69123bb93b9ccaad6 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 4 Nov 2025 09:42:11 +0100 Subject: [PATCH 075/267] Add chunk_size parameter to ProductModel initialization in DatabaseProductsPane --- activity_browser/layouts/panes/database_products.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activity_browser/layouts/panes/database_products.py b/activity_browser/layouts/panes/database_products.py index 86fa67c7a..4c133f7c9 100644 --- a/activity_browser/layouts/panes/database_products.py +++ b/activity_browser/layouts/panes/database_products.py @@ -44,7 +44,7 @@ def __init__(self, parent, db_name: str): super().__init__(parent) self.database = bd.Database(db_name) self.title = db_name - self.model = ProductModel(parent=self) + self.model = ProductModel(parent=self, chunk_size=100) # Create the QTableView and set the model self.table_view = ProductView(self, db_name=db_name) From afcebbd1e1291e25707ff0acec4bad02d97ace52 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 4 Nov 2025 10:41:00 +0100 Subject: [PATCH 076/267] Enhance group method in ABTreeModel to unpack iterables into separate columns for multiindexing during DataFrame regrouping. --- activity_browser/ui/core/tree_model.py | 42 +++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 55011269a..33a4a3595 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -351,15 +351,43 @@ def set_dataframe(self, df: pd.DataFrame) -> None: self.endResetModel() def group(self, columns: list[str]) -> None: - """Regroup the DataFrame by the specified columns.""" - # Set the new index with specified columns - current_index_names = self.df.index.names - new_index_names = columns + current_index_names - df = self.df.reset_index() - new_index = pd.MultiIndex.from_frame(df[new_index_names]) + """Regroup the DataFrame by the specified columns. + + Unpacks columns containing iterables (lists, tuples, sets) by spreading them + into separate columns that become separate levels in the multiindex. + """ + df = self.df[columns].copy() + + # Build the list of columns for the new index, unpacking iterables + for col in columns: + # Check if the column contains iterables (excluding strings) + sample_val = df[col].dropna().iloc[0] if not df[col].dropna().empty else None + + if not isinstance(sample_val, (list, tuple, set)): + continue + + # Unpack the iterable into separate columns + unpacked = pd.DataFrame(df[col].tolist(), index=df.index) + + # Name the new columns + unpacked.columns = [f"{col}_{i}" for i in range(len(unpacked.columns))] + + # Add unpacked columns to the dataframe + for unpacked_col in unpacked.columns: + df[unpacked_col] = unpacked[unpacked_col] + + # Remove the original column from the dataframe + df = df.drop(columns=[col]) + + levels = list(df.columns) + list(df.index.names) + + df = df.reset_index() + df = df[levels] + + new_index = pd.MultiIndex.from_frame(df) new_index.names = [i+"_i" if not i.endswith("_i") else i for i in new_index.names] - self.df.set_index(new_index, inplace=True) + self.df = self.df.set_index(new_index) self.reset_hierarchy() From 7c09dd149eb66885ab3f31d31d6e298f92156069 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 4 Nov 2025 12:08:37 +0100 Subject: [PATCH 077/267] Refactor parent_path method in ABTreeModel to filter out NaN values and improve path handling. Update index method to utilize the new parent_path implementation. Remove redundant import of defaultdict in build_hierarchy_from_index method. --- activity_browser/ui/core/tree_model.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 33a4a3595..d362b2c00 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -1,4 +1,5 @@ from typing import Optional +from collections import defaultdict from loguru import logger import pandas as pd @@ -75,17 +76,21 @@ def parent(self, index: QModelIndex) -> QModelIndex: # Full path for the current index path = index.internalPointer() - parent_path = path[:-1] + parent_path = self.parent_path(path) if len(parent_path) == 0: return QModelIndex() - grandparent_path = parent_path[:-1] + grandparent_path = self.parent_path(parent_path) grandparent_children = self.children_map.get(grandparent_path, []) # children_map stores full paths; find the parent's row among its siblings row = grandparent_children.index(parent_path) return self.createIndex(row, 0, grandparent_children[row]) + + def parent_path(self, path: tuple) -> tuple: + path = tuple(val for val in path if not pd.isna(val)) + return path[:-1] def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: # For tree models, when the parent is valid and column > 0, return 0 @@ -230,13 +235,11 @@ def fetchMore(self, parent: QModelIndex) -> None: # --- helper functions --- def build_hierarchy_from_index(self, pandas_index: pd.Index) -> dict[tuple, list[tuple]]: - from collections import defaultdict children_map = defaultdict(list) # Convert index to frame once for all operations idx_df = pandas_index.to_frame(index=False) - - + # Process each level for level in range(idx_df.shape[1]): # Get unique child paths at this level (as tuples) @@ -253,6 +256,10 @@ def build_hierarchy_from_index(self, pandas_index: pd.Index) -> dict[tuple, list # Build parent->children mapping efficiently with zip for parent, child in zip(parent_tuples, child_tuples): + if pd.isna(child[-1]): + continue # skip NaN children + parent = tuple(val for val in parent if not pd.isna(val)) + children_map[parent].append(child) return dict(children_map) From 48b9835cff06f13e988d3439d6ffffc999f54255 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 4 Nov 2025 13:28:59 +0100 Subject: [PATCH 078/267] Update displayData method in ABTreeModel to handle branch nodes correctly and ensure leaf nodes return empty for non-tree columns. Reset DataFrame index name in ungroup method. --- activity_browser/ui/core/tree_model.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index d362b2c00..9104fd3b5 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -127,13 +127,12 @@ def data(self, index: QModelIndex, role: int = Qt.DisplayRole): def displayData(self, index: QModelIndex) -> any: path = index.internalPointer() + if len(path) < self.df.index.nlevels: # branch node + return path[-1] if index.column() == 0 else None + if index.column() == 0: - return path[-1] # last element in the path + return None # leaf node tree column is empty - # Only show data columns for leaf nodes (full depth paths) - if len(path) < self.df.index.nlevels: - return None # intermediate nodes have no data in non-tree columns - col_name = self.headerData(index.column()) val = self.df.at[path[0] if len(path) == 1 else path, col_name] return val @@ -401,4 +400,5 @@ def group(self, columns: list[str]) -> None: def ungroup(self) -> None: """Ungroup the DataFrame by resetting the index.""" self.df.reset_index(drop=True, inplace=True) + self.df.index.name = "index" self.reset_hierarchy() From beb4ccfe22472d47fdbc5823d29879647bcb9e60 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 4 Nov 2025 15:23:55 +0100 Subject: [PATCH 079/267] Refactor DatabasesPane and DatabasesView to improve model initialization and data handling. Update index management in ABTreeModel and add values_from_indices method for better data retrieval from QModelIndex. --- .../layouts/panes/database_products.py | 15 --- activity_browser/layouts/panes/databases.py | 110 ++++++++++-------- activity_browser/ui/core/tree_model.py | 23 +++- 3 files changed, 81 insertions(+), 67 deletions(-) diff --git a/activity_browser/layouts/panes/database_products.py b/activity_browser/layouts/panes/database_products.py index 4c133f7c9..01acb81d0 100644 --- a/activity_browser/layouts/panes/database_products.py +++ b/activity_browser/layouts/panes/database_products.py @@ -390,18 +390,3 @@ def mimeData(self, indices: list[QtCore.QModelIndex]): keys = {key for key in keys if isinstance(key, tuple)} data.setPickleData("application/bw-nodekeylist", list(keys)) return data - - def values_from_indices(self, key: str, indices: list[QtCore.QModelIndex]): - """ - Returns the values from the given indices. - - Args: - key (str): The key to get the values for. - indices (list[QtCore.QModelIndex]): The indices to get the values for. - - Returns: - list: The list of values. - """ - column = self.df.columns.get_loc(key) - return [index.data(Qt.ItemDataRole.DisplayRole) for index in indices if index.column() == column] - diff --git a/activity_browser/layouts/panes/databases.py b/activity_browser/layouts/panes/databases.py index 214e4416e..d474348d1 100644 --- a/activity_browser/layouts/panes/databases.py +++ b/activity_browser/layouts/panes/databases.py @@ -1,13 +1,13 @@ import datetime -from qtpy import QtWidgets, QtGui +from qtpy import QtWidgets, QtGui, QtCore from qtpy.QtCore import Qt import bw2data as bd import pandas as pd from activity_browser import signals, actions, bwutils -from activity_browser.ui import widgets, icons, delegates +from activity_browser.ui import widgets, icons, delegates, core from activity_browser.layouts.menu_bar import ImportDatabaseMenu @@ -30,8 +30,8 @@ def __init__(self, parent): parent (QtWidgets.QWidget): The parent widget. """ super().__init__(parent) + self.model = DatabasesModel(parent=self) self.view = DatabasesView() - self.model = DatabasesModel() self.view.setModel(self.model) self.view.setAlternatingRowColors(True) @@ -62,9 +62,11 @@ def sync(self): """ Synchronizes the model with the current state of the databases. """ - self.model.setDataFrame(self.build_df()) - self.view.resizeColumnToContents(0) - self.view.header().setSectionResizeMode(0, QtWidgets.QHeaderView.Fixed) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) + self.view.resizeColumnToContents(1) + self.view.header().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) def build_df(self) -> pd.DataFrame: """ @@ -97,7 +99,7 @@ def build_df(self) -> pd.DataFrame: return pd.DataFrame(data, columns=cols) -class DatabasesView(widgets.ABTreeView): +class DatabasesView(widgets.ABNewTreeView): """ A view that displays the databases in a tree structure. @@ -153,7 +155,9 @@ def selected_readonly(self): """ if not self.parent().selected_databases: return None - return self.parent().selectedIndexes()[0].internalPointer()["read_only"] + index = self.parent().selectedIndexes()[0] + row = self.parent().model().row(index) + return row.get("read_only") if row is not None else None class HeaderMenu(QtWidgets.QMenu): """ @@ -175,10 +179,14 @@ def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent): if not index.isValid(): return super().mouseDoubleClickEvent(event) - db_name = index.internalPointer()["name"] + row = self.model().row(index) + if row is None: + return super().mouseDoubleClickEvent(event) + + db_name = row.get("name") - if index.column() == 0: - read_only = index.internalPointer()["read_only"] + if index.column() == 1: + read_only = row.get("read_only") actions.DatabaseSetReadonly.run(db_name, not read_only) return @@ -208,69 +216,75 @@ def selected_databases(self) -> list: """ if not self.selectedIndexes(): return [] - return list(set([i.internalPointer()["name"] for i in self.selectedIndexes()])) + names = self.model().values_from_indices("name", self.selectedIndexes()) + return list(set(names)) -class DatabasesItem(widgets.ABDataItem): +class DatabasesModel(core.ABTreeModel): """ - An item representing a database in the tree view. + A model representing the data for the databases. """ - def decorationData(self, col: int, key: str): + def decorationData(self, index: QtCore.QModelIndex) -> any: """ - Provides decoration data for the item. + Provides decoration data for the model. Args: - col (int): The column index. - key (str): The key for which to provide decoration data. + index (QtCore.QModelIndex): The index for which to provide decoration data. Returns: - The decoration data for the item. + The decoration data for the index. """ - if key == "read_only": - return icons.qicons.locked if self["read_only"] else icons.qicons.empty - return super().decorationData(col, key) + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return None - def displayData(self, col: int, key: str): + if column_name == "read_only": + return icons.qicons.locked if row.get("read_only") else icons.qicons.empty + + return None + + def displayData(self, index: QtCore.QModelIndex) -> any: """ - Provides display data for the item. + Provides display data for the model. Args: - col (int): The column index. - key (str): The key for which to provide display data. + index (QtCore.QModelIndex): The index for which to provide display data. Returns: - The display data for the item. + The display data for the index. """ - if key == "read_only": + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return None + + if column_name == "read_only": return None - return super().displayData(col, key) - def fontData(self, col: int, key: str): + return row.get(column_name) + + def fontData(self, index: QtCore.QModelIndex) -> any: """ - Provides font data for the item. + Provides font data for the model. Args: - col (int): The column index. - key (str): The key for which to provide font data. + index (QtCore.QModelIndex): The index for which to provide font data. Returns: - QtGui.QFont: The font data for the item. + QtGui.QFont: The font data for the index. """ - font = super().fontData(col, key) - if key == "name": - font.setWeight(QtGui.QFont.Weight.DemiBold) - return font + column_name = self.column_name(index) + if column_name == "name": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font -class DatabasesModel(widgets.ABItemModel): - """ - A model representing the data for the databases. - - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = DatabasesItem + return None def headerData(self, section, orientation=Qt.Orientation.Horizontal, role=Qt.ItemDataRole.DisplayRole): """ @@ -284,8 +298,8 @@ def headerData(self, section, orientation=Qt.Orientation.Horizontal, role=Qt.Ite Returns: The header data for the model. """ - if section == 0 and role == Qt.ItemDataRole.DisplayRole: + if section == 1 and role == Qt.ItemDataRole.DisplayRole: return "" - if section == 0 and role == Qt.ItemDataRole.DecorationRole: + if section == 1 and role == Qt.ItemDataRole.DecorationRole: return icons.qicons.unlocked return super().headerData(section, orientation, role) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 9104fd3b5..370a32017 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -134,7 +134,7 @@ def displayData(self, index: QModelIndex) -> any: return None # leaf node tree column is empty col_name = self.headerData(index.column()) - val = self.df.at[path[0] if len(path) == 1 else path, col_name] + val = self.df.at[path, col_name] return val def editData(self, index: QModelIndex) -> any: @@ -348,8 +348,7 @@ def set_dataframe(self, df: pd.DataFrame) -> None: self.df = df if not all(self.df.index.names): logger.warning("DataFrame index has unnamed levels; resetting to default integer index.") - self.df.reset_index(drop=True, inplace=True) - self.df.index.name = "index" + self.df.index = pd.MultiIndex.from_arrays([range(len(self.df))], names=[f"index"]) self.df.index.names = [name + "_i" for name in self.df.index.names] # append _i to index level names to avoid conflicts @@ -399,6 +398,22 @@ def group(self, columns: list[str]) -> None: def ungroup(self) -> None: """Ungroup the DataFrame by resetting the index.""" - self.df.reset_index(drop=True, inplace=True) + self.df.index = pd.MultiIndex.from_arrays([range(len(self.df))], names=[f"index"]) self.df.index.name = "index" self.reset_hierarchy() + + def values_from_indices(self, key: str, indices: list[QModelIndex]): + """ + Returns the values from the given indices. + + Args: + key (str): The key to get the values for. + indices (list[QtCore.QModelIndex]): The indices to get the values for. + + Returns: + list: The list of values. + """ + paths = {index.internalPointer() for index in indices if index.isValid()} + paths = [path for path in paths if len(path) == self.df.index.nlevels] # only leaf nodes + return self.df.loc[paths, key].tolist() + From f12bd8e83f36948df44a32fdac333439ef1de9e8 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 5 Nov 2025 09:55:57 +0100 Subject: [PATCH 080/267] Optimize row index lookups in ABTreeModel by implementing a pre-computed mapping for O(1) access. Refactor index and reset_hierarchy methods to utilize the new row_indices structure. --- activity_browser/ui/core/tree_model.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 370a32017..a5b5c0c4d 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -17,6 +17,10 @@ def __init__(self, df: pd.DataFrame = None, parent: Optional[QWidget] = None, ch self.lazy = chunk_size > 0 self.chunk_size = chunk_size + # Pre-compute row indices for O(1) lookups instead of O(n) list.index() + self.row_indices: dict[tuple, dict[tuple, int]] = {} + self.build_row_indices() + # Track how many children are currently loaded for each parent self.loaded_counts: dict[tuple, int] = {} if self.lazy: @@ -82,11 +86,10 @@ def parent(self, index: QModelIndex) -> QModelIndex: return QModelIndex() grandparent_path = self.parent_path(parent_path) - grandparent_children = self.children_map.get(grandparent_path, []) - # children_map stores full paths; find the parent's row among its siblings - row = grandparent_children.index(parent_path) + # Use pre-computed row index for O(1) lookup instead of O(n) list.index() + row = self.row_indices[grandparent_path][parent_path] - return self.createIndex(row, 0, grandparent_children[row]) + return self.createIndex(row, 0, parent_path) def parent_path(self, path: tuple) -> tuple: path = tuple(val for val in path if not pd.isna(val)) @@ -263,6 +266,12 @@ def build_hierarchy_from_index(self, pandas_index: pd.Index) -> dict[tuple, list return dict(children_map) + def build_row_indices(self) -> None: + """Build a mapping of parent_path -> {child_path: row_index} for O(1) lookups.""" + self.row_indices = {} + for parent_path, children in self.children_map.items(): + self.row_indices[parent_path] = {child: idx for idx, child in enumerate(children)} + def reset_hierarchy(self, df: pd.DataFrame = None) -> None: df = df if df is not None else self.df @@ -271,6 +280,7 @@ def reset_hierarchy(self, df: pd.DataFrame = None) -> None: old_persistent_paths = [idx.internalPointer() for idx in self.persistentIndexList()] self.children_map = self.build_hierarchy_from_index(df.index) + self.build_row_indices() # Reset loaded counts for lazy loading self.loaded_counts = {} @@ -287,9 +297,9 @@ def reset_hierarchy(self, df: pd.DataFrame = None) -> None: new_persistent = [] for path, index in zip(old_persistent_paths, self.persistentIndexList()): parent_path = path[:-1] - if parent_path in self.children_map and path in self.children_map[parent_path]: - row = self.children_map[parent_path].index(path) - new_index = self.createIndex(row, index.column(), self.children_map[parent_path][row]) + if parent_path in self.row_indices and path in self.row_indices[parent_path]: + row = self.row_indices[parent_path][path] + new_index = self.createIndex(row, index.column(), path) new_persistent.append(new_index) else: new_persistent.append(QModelIndex()) From 54660059553b50ac71cfe99fb9d3f83a0f334c69 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 5 Nov 2025 10:39:49 +0100 Subject: [PATCH 081/267] Refactor ImpactCategoriesPane and ImpactCategoriesView for improved layout and signal connections. Enhance ImpactCategoriesModel with drag-and-drop functionality for better user interaction. --- .../layouts/panes/calculation_setups.py | 87 ++++++------- .../layouts/panes/impact_categories.py | 115 +++++++++--------- 2 files changed, 100 insertions(+), 102 deletions(-) diff --git a/activity_browser/layouts/panes/calculation_setups.py b/activity_browser/layouts/panes/calculation_setups.py index f88437867..821219415 100644 --- a/activity_browser/layouts/panes/calculation_setups.py +++ b/activity_browser/layouts/panes/calculation_setups.py @@ -4,7 +4,7 @@ import pandas as pd from activity_browser import signals, actions -from activity_browser.ui import widgets, delegates +from activity_browser.ui import widgets, delegates, core class CalculationSetupsPane(widgets.ABAbstractPane): @@ -23,8 +23,8 @@ def __init__(self, parent): parent (QtWidgets.QWidget): The parent widget for this pane. """ super().__init__(parent) + self.model = CalculationSetupsModel(parent=self) self.view = CalculationSetupsView() - self.model = CalculationSetupsModel() self.view.setModel(self.model) self.view.setAlternatingRowColors(True) @@ -52,7 +52,9 @@ def sync(self): """ Synchronizes the model with the current state of the calculation setups. """ - self.model.setDataFrame(self.build_df()) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) self.view.resizeColumnToContents(0) def build_df(self) -> pd.DataFrame: @@ -77,7 +79,7 @@ def build_df(self) -> pd.DataFrame: return pd.DataFrame(data, columns=cols) -class CalculationSetupsView(widgets.ABTreeView): +class CalculationSetupsView(widgets.ABNewTreeView): """ A view that displays the calculation setups in a tree structure. @@ -90,25 +92,28 @@ class CalculationSetupsView(widgets.ABTreeView): class ContextMenu(widgets.ABMenu): menuSetup = [ - lambda menu: menu.add(actions.CSNew), - lambda menu: menu.add(actions.CSOpen, menu.calculation_setups, - enable=bool(menu.calculation_setups)), - lambda menu: menu.add(actions.CSDelete, menu.calculation_setups, - enable=bool(menu.calculation_setups)), - lambda menu: menu.add(actions.CSRename, menu.calculation_setups[0] if menu.single_selection else None, - enable=menu.single_selection), - lambda menu: menu.addSeparator(), - lambda menu: menu.add(actions.CSCalculate, menu.calculation_setups[0] if menu.single_selection else None, - enable=menu.single_selection), + lambda m, p: m.add(actions.CSNew), + lambda m, p: m.add(actions.CSOpen, p.calculation_setups, + enable=bool(p.calculation_setups)), + lambda m, p: m.add(actions.CSDelete, p.calculation_setups, + enable=bool(p.calculation_setups)), + lambda m, p: m.add(actions.CSRename, p.calculation_setups[0] if p.single_selection else None, + enable=p.single_selection), + lambda m: m.addSeparator(), + lambda m, p: m.add(actions.CSCalculate, p.calculation_setups[0] if p.single_selection else None, + enable=p.single_selection), ] - @property - def calculation_setups(self): - return [item["name"] for item in {index.internalPointer() for index in self.parent().selectedIndexes()}] + @property + def calculation_setups(self): + if not self.selectedIndexes(): + return [] + names = self.model().values_from_indices("name", self.selectedIndexes()) + return list(set(names)) - @property - def single_selection(self): - return len(self.calculation_setups) == 1 + @property + def single_selection(self): + return len(self.calculation_setups) == 1 class HeaderMenu(QtWidgets.QMenu): """ @@ -129,12 +134,16 @@ def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent): Args: event (QtGui.QMouseEvent): The mouse double click event. """ - if not self.selectedIndexes(): + index = self.indexAt(event.pos()) + + if not index.isValid(): return - index = self.indexAt(event.pos()) + row = self.model().row(index) + if row is None: + return - actions.CSOpen.run(index.internalPointer()["name"]) + actions.CSOpen.run(row["name"]) def dragMoveEvent(self, event) -> None: @@ -167,33 +176,27 @@ def dropEvent(self, event) -> None: actions.CSNew.run(functional_units=functional_units) -class CalculationSetupsItem(widgets.ABDataItem): +class CalculationSetupsModel(core.ABTreeModel): """ - An item representing a calculation setup in the tree view. + A model representing the data for the calculation setups. """ - def fontData(self, col: int, key: str): + + def fontData(self, index): """ - Provides font data for the item. + Provides font data for the model. Args: - col (int): The column index. - key (str): The key for which to provide font data. + index: The index for which to provide font data. Returns: - QtGui.QFont: The font data for the item. + QtGui.QFont: The font data for the index. """ - font = super().fontData(col, key) - if key == "name": - font.setWeight(QtGui.QFont.Weight.DemiBold) - return font - + column_name = self.column_name(index) -class CalculationSetupsModel(widgets.ABItemModel): - """ - A model representing the data for the databases. + if column_name == "name": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = CalculationSetupsItem + return None diff --git a/activity_browser/layouts/panes/impact_categories.py b/activity_browser/layouts/panes/impact_categories.py index a4216874f..4bc51e865 100644 --- a/activity_browser/layouts/panes/impact_categories.py +++ b/activity_browser/layouts/panes/impact_categories.py @@ -1,4 +1,4 @@ -from qtpy import QtWidgets, QtCore +from qtpy import QtWidgets, QtCore, QtGui from qtpy.QtCore import Qt import bw2data as bd @@ -14,8 +14,8 @@ class ImpactCategoriesPane(widgets.ABAbstractPane): def __init__(self, parent=None): super().__init__(parent) + self.model = ImpactCategoriesModel(parent=self) self.view = ImpactCategoriesView() - self.model = ImpactCategoriesModel() self.view.setModel(self.model) self.view.setSelectionMode(QtWidgets.QTableView.SingleSelection) @@ -47,24 +47,26 @@ def connect_signals(self): signals.database_read_only_changed.connect(self.sync) def load(self): - self.model.setDataFrame(self.build_df()) - self.model.group(1) - self.view.setColumnHidden(1, True) - self.view.setColumnHidden(2, True) - self.view.setColumnHidden(3, True) - self.view.sortByColumn(1, Qt.SortOrder.AscendingOrder) + df = self.build_df() + self.model.set_dataframe(df) + # self.view.setColumnHidden(1, True) + # self.view.setColumnHidden(2, True) + # self.view.setColumnHidden(3, True) + # self.view.sortByColumn(1, Qt.SortOrder.AscendingOrder) def sync(self): - self.model.setDataFrame(self.build_df()) + df = self.build_df() + self.model.set_dataframe(df) 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]) - df["groups"] = df["_method_name"].apply(lambda x: x[:-1]) + df.index = pd.MultiIndex.from_tuples(df["_method_name"]) + df.index.names = [f"index{i}" for i in range(df.index.nlevels)] - cols = ["name", "groups", "unit", "num_cfs", "_method_name"] + cols = ["name", "unit", "num_cfs", "_method_name"] if df.empty: return pd.DataFrame(columns=cols) @@ -72,7 +74,7 @@ def build_df(self): return df[cols] -class ImpactCategoriesView(widgets.ABTreeView): +class ImpactCategoriesView(widgets.ABNewTreeView): defaultColumnDelegates = { "groups": delegates.ListDelegate, } @@ -100,19 +102,11 @@ class ContextMenu(widgets.ABMenu): ), ] - @staticmethod - def get_functional_unit_amount(key): - from activity_browser.bwutils import refresh_node - excs = list(refresh_node(key).upstream(["production"])) - exc = excs[0] if len(excs) == 1 else {} - return exc.get("amount", 1.0) - - @property - def database_name(self): - return self.parent().parent().database.name - @property def selected_impact_categories(self): + if not self.selectedIndexes(): + return [] + indices = [i for i in self.selectedIndexes() if i.column() == 0] impact_categories = [] @@ -126,39 +120,14 @@ def mouseDoubleClickEvent(self, event) -> None: actions.MethodOpen.run(self.selected_impact_categories) -class ImpactCategoriesItem(widgets.ABDataItem): - def flags(self, col: int, key: str): - """ - Returns the item flags for the given column and key. - - Args: - col (int): The column index. - key (str): The key for which to return the flags. - - Returns: - QtCore.Qt.ItemFlags: The item flags. - """ - return super().flags(col, key) | Qt.ItemFlag.ItemIsDragEnabled - - -class ImpactCategoriesBranchItem(widgets.ABBranchItem): - def flags(self, col: int, key: str): - """ - Returns the item flags for the given column and key. - - Args: - col (int): The column index. - key (str): The key for which to return the flags. - - Returns: - QtCore.Qt.ItemFlags: The item flags. - """ - return super().flags(col, key) | Qt.ItemFlag.ItemIsDragEnabled - +class ImpactCategoriesModel(core.ABTreeModel): + """ + A model representing the data for the impact categories. + """ -class ImpactCategoriesModel(widgets.ABItemModel): - dataItemClass = ImpactCategoriesItem - branchItemClass = ImpactCategoriesBranchItem + def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: + """Enable drag for all items.""" + return True def mimeData(self, indices: list[QtCore.QModelIndex]): """ @@ -180,12 +149,38 @@ def mimeData(self, indices: list[QtCore.QModelIndex]): return data def get_impact_categories(self, index: QtCore.QModelIndex): - if isinstance(index.internalPointer(), self.dataItemClass): - return [index.internalPointer()["_method_name"]] - + """ + Get all impact category method names for the given index. + + For leaf nodes (full depth paths), returns the single method name. + For branch nodes (partial depth paths), returns all child method names. + + Args: + index: The index to get impact categories for. + + Returns: + list: List of method name tuples. + """ + if not index.isValid(): + return [] + + path = index.internalPointer() + + # If this is a leaf node (full depth), return its method name + if len(path) == self.df.index.nlevels: + row = self.row(index) + if row is not None: + return [row["_method_name"]] + return [] + + # If this is a branch node, collect all child method names ics = [] - for i, child in enumerate(index.internalPointer().children().values()): - child_index = self.createIndex(i, 0, child) + children = self.children_map.get(path, []) + for child_path in children: + # Create an index for the child and recursively get its impact categories + row_idx = self.row_indices[path][child_path] + child_index = self.createIndex(row_idx, 0, child_path) ics += self.get_impact_categories(child_index) + return ics From 0e19771bf3d0ec2bcfd66b008c965aec352b6ec0 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 5 Nov 2025 11:14:17 +0100 Subject: [PATCH 082/267] Refactor index method in ABTreeModel to remove redundant index check and improve parent path handling for optimized index creation. --- activity_browser/ui/core/tree_model.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index a5b5c0c4d..fc00d245a 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -56,9 +56,6 @@ def row(self, index: QModelIndex) -> pd.Series | None: # --- required model overrides --- def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex: - if not self.hasIndex(row, column, parent): - return QModelIndex() - parent_path = parent.internalPointer() or tuple() all_children = self.children_map.get(parent_path, []) @@ -89,7 +86,7 @@ def parent(self, index: QModelIndex) -> QModelIndex: # Use pre-computed row index for O(1) lookup instead of O(n) list.index() row = self.row_indices[grandparent_path][parent_path] - return self.createIndex(row, 0, parent_path) + return self.createIndex(row, 0, self.children_map[grandparent_path][row]) def parent_path(self, path: tuple) -> tuple: path = tuple(val for val in path if not pd.isna(val)) From 6ff359f87ac1ff581be939ebc80bfa266a1b0620 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 5 Nov 2025 11:22:21 +0100 Subject: [PATCH 083/267] Refactor load and sync methods in ImpactCategoriesPane to ensure consistent grouping by "_method_name" after setting the dataframe. --- activity_browser/layouts/panes/impact_categories.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activity_browser/layouts/panes/impact_categories.py b/activity_browser/layouts/panes/impact_categories.py index 4bc51e865..04fcfcd54 100644 --- a/activity_browser/layouts/panes/impact_categories.py +++ b/activity_browser/layouts/panes/impact_categories.py @@ -49,6 +49,7 @@ def connect_signals(self): def load(self): df = self.build_df() self.model.set_dataframe(df) + self.model.group(["_method_name"]) # self.view.setColumnHidden(1, True) # self.view.setColumnHidden(2, True) # self.view.setColumnHidden(3, True) @@ -57,14 +58,13 @@ def load(self): def sync(self): df = self.build_df() self.model.set_dataframe(df) + self.model.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]) - df.index = pd.MultiIndex.from_tuples(df["_method_name"]) - df.index.names = [f"index{i}" for i in range(df.index.nlevels)] cols = ["name", "unit", "num_cfs", "_method_name"] From 210ab00c6a155722ee55b50df49efb1eb760c4f1 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 5 Nov 2025 11:55:44 +0100 Subject: [PATCH 084/267] Enhance ABTreeModel and ABNewTreeView with branch node handling. Implement isBranchNode method for model and updateBranchSpanning in view to enable spanning for branch nodes across all columns. --- activity_browser/ui/core/tree_model.py | 8 +++++ activity_browser/ui/widgets/tree_view.py | 41 +++++++++++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index fc00d245a..4aafdb2b7 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -128,6 +128,8 @@ def displayData(self, index: QModelIndex) -> any: path = index.internalPointer() if len(path) < self.df.index.nlevels: # branch node + # For branch nodes, show the name in the first column only + # (spanning will be handled by the view) return path[-1] if index.column() == 0 else None if index.column() == 0: @@ -184,6 +186,12 @@ def indexDropEnabled(self, index: QModelIndex) -> bool: def indexUserCheckable(self, index: QModelIndex) -> bool: return False + def isBranchNode(self, index: QModelIndex) -> bool: + """Check if the given index represents a branch node (non-leaf).""" + if not index.isValid(): + return False + path = index.internalPointer() + return len(path) < self.df.index.nlevels def headerData(self, section: int, orientation: Qt.Orientation = Qt.Horizontal, role: int = Qt.DisplayRole): if orientation == Qt.Vertical or not role == Qt.DisplayRole: diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py index 1dbc99463..0691d481e 100644 --- a/activity_browser/ui/widgets/tree_view.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -71,7 +71,7 @@ def __init__(self, pos, view): def __init__(self, parent=None): super().__init__(parent) - + self.setIndentation(10) self.setUniformRowHeights(True) self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) @@ -80,9 +80,8 @@ def __init__(self, parent=None): self.setSelectionBehavior(QtWidgets.QTreeView.SelectionBehavior.SelectRows) self.setSelectionMode(QtWidgets.QTreeView.SelectionMode.ExtendedSelection) - header = self.header() - header.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - header.customContextMenuRequested.connect(self.showHeaderMenu) + self.header().setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.header().customContextMenuRequested.connect(self.showHeaderMenu) self.columnFilters: dict[str, str] = {} # dict[column_name, query] for filtering the dataframe self.allFilter: str = "" # filter applied to the entire dataframe @@ -90,12 +89,17 @@ def __init__(self, parent=None): def setModel(self, model): super().setModel(model) + self.setColumnWidth(0, 30) + self.header().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) + model.modelAboutToBeReset.connect(self.clearColumnDelegates) model.modelReset.connect(self.setDefaultColumnDelegates) model.layoutChanged.connect(self.updateIndexColumnVisibility) + model.layoutChanged.connect(self.updateBranchSpanning) self.setDefaultColumnDelegates() self.updateIndexColumnVisibility() + self.updateBranchSpanning() def model(self) -> ABItemModel: return super().model() @@ -199,4 +203,33 @@ def updateIndexColumnVisibility(self): # Hide index column if it's only one level deep hide_index = model.df.index.nlevels == 1 self.setColumnHidden(0, hide_index) + + def updateBranchSpanning(self): + """Enable spanning for branch nodes so they span across all columns.""" + model = self.model() + if model is None or not hasattr(model, 'isBranchNode'): + return + + # Recursively set spanning for all branch nodes + self._setSpanningRecursive(QtCore.QModelIndex()) + + def _setSpanningRecursive(self, parent: QtCore.QModelIndex): + """Recursively set first column spanning for branch nodes.""" + model = self.model() + if model is None: + return + + row_count = model.rowCount(parent) + for row in range(row_count): + index = model.index(row, 0, parent) + if not index.isValid(): + continue + + # Check if this is a branch node + if hasattr(model, 'isBranchNode') and model.isBranchNode(index): + self.setFirstColumnSpanned(row, parent, True) + # Recursively process children + self._setSpanningRecursive(index) + else: + self.setFirstColumnSpanned(row, parent, False) From a9742c41ae611e735084eae7aac2de7e636b11cf Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 5 Nov 2025 11:56:04 +0100 Subject: [PATCH 085/267] Remove event handling for deferred delete in DatabaseProductsPane to simplify state management. --- .../layouts/panes/database_products.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/activity_browser/layouts/panes/database_products.py b/activity_browser/layouts/panes/database_products.py index 01acb81d0..1aaf892b0 100644 --- a/activity_browser/layouts/panes/database_products.py +++ b/activity_browser/layouts/panes/database_products.py @@ -187,21 +187,6 @@ def on_database_deleted(self, db_name: str): if db_name == self.database.name: self.deleteLater() - def event(self, event): - """ - Handles the event to save the state to settings on deferred delete. - - Args: - event: The event to handle. - - Returns: - bool: True if the event was handled, False otherwise. - """ - if event.type() == QtCore.QEvent.Type.DeferredDelete: - self.save_state_to_settings() - - return super().event(event) - def search_error(self, reset=False): """ Handles the search error by changing the search bar color. From e6abca7bfccf601a58cce2a83233914efc54e10a Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 5 Nov 2025 12:10:51 +0100 Subject: [PATCH 086/267] Refactor set_dataframe method in ABTreeModel to ensure a default integer index is set, removing the check for unnamed index levels. --- activity_browser/ui/core/tree_model.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 4aafdb2b7..cf41064a8 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -361,11 +361,7 @@ def quick_filter(self, substring: str) -> None: def set_dataframe(self, df: pd.DataFrame) -> None: self.beginResetModel() self.df = df - if not all(self.df.index.names): - logger.warning("DataFrame index has unnamed levels; resetting to default integer index.") - self.df.index = pd.MultiIndex.from_arrays([range(len(self.df))], names=[f"index"]) - - self.df.index.names = [name + "_i" for name in self.df.index.names] # append _i to index level names to avoid conflicts + self.df.index = pd.MultiIndex.from_arrays([range(len(self.df))], names=[f"index"]) self.reset_hierarchy() self.endResetModel() From b381f9e5e3fd256acef32ec18b50f157b0af89ef Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 5 Nov 2025 12:23:55 +0100 Subject: [PATCH 087/267] Add StringDelegate to ABNewTreeView for improved item rendering --- activity_browser/ui/widgets/tree_view.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py index 0691d481e..32e59bdc4 100644 --- a/activity_browser/ui/widgets/tree_view.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -5,6 +5,7 @@ from qtpy import QtWidgets, QtCore, QtGui from qtpy.QtCore import Qt +from activity_browser.ui import delegates, core from .item_model import ABItemModel @@ -73,6 +74,7 @@ def __init__(self, parent=None): super().__init__(parent) self.setIndentation(10) self.setUniformRowHeights(True) + self.setItemDelegate(delegates.StringDelegate(self)) self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.showContextMenu) From fe7db553b368da4c3626a2fe204429476d0fdfc4 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 5 Nov 2025 13:12:31 +0100 Subject: [PATCH 088/267] Fix index creation in ABTreeModel and improve sort method handling for empty DataFrame --- activity_browser/ui/core/tree_model.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index cf41064a8..455e4fb43 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -304,7 +304,8 @@ def reset_hierarchy(self, df: pd.DataFrame = None) -> None: parent_path = path[:-1] if parent_path in self.row_indices and path in self.row_indices[parent_path]: row = self.row_indices[parent_path][path] - new_index = self.createIndex(row, index.column(), path) + true_path = self.children_map[parent_path][row] + new_index = self.createIndex(row, index.column(), true_path) new_persistent.append(new_index) else: new_persistent.append(QModelIndex()) @@ -316,6 +317,8 @@ def reset_hierarchy(self, df: pd.DataFrame = None) -> None: def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: + if self.df.empty: + return # Extract the unique order of higher levels column_name = self.headerData(column) if column > 0 else self.df.index.names[-1] higher_levels = self.df.index.droplevel(-1).unique() if self.df.index.nlevels > 1 else [None] @@ -325,9 +328,9 @@ def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) - for lvl in higher_levels: mask = self.df.index.droplevel(-1) == lvl if lvl is not None else self.df.index - partial_df = self.df.loc[mask] + partial_df = self.df.loc[mask, column_name].copy() if column_name is not None: - partial_df.sort_values(by=column_name, ascending=(order == Qt.SortOrder.AscendingOrder), inplace=True) + partial_df.sort_values(ascending=(order == Qt.SortOrder.AscendingOrder), inplace=True) else: partial_df = partial_df.sort_index(ascending=(order == Qt.SortOrder.AscendingOrder)) sorted_index.append(partial_df.index) From bdb49129ffc5fd41a3a68dc187a5dcfcde331f21 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 5 Nov 2025 15:16:49 +0100 Subject: [PATCH 089/267] Fix column name handling in sorting logic for empty DataFrame in ABTreeModel --- activity_browser/ui/core/tree_model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 455e4fb43..ae6e6d1f3 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -320,7 +320,7 @@ def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) - if self.df.empty: return # Extract the unique order of higher levels - column_name = self.headerData(column) if column > 0 else self.df.index.names[-1] + column_name = self.headerData(column) if column > 0 else None higher_levels = self.df.index.droplevel(-1).unique() if self.df.index.nlevels > 1 else [None] # Build a new index by sorting only within each higher level @@ -328,7 +328,7 @@ def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) - for lvl in higher_levels: mask = self.df.index.droplevel(-1) == lvl if lvl is not None else self.df.index - partial_df = self.df.loc[mask, column_name].copy() + partial_df = self.df.loc[mask, column_name or self.df.columns[0]].copy() if column_name is not None: partial_df.sort_values(ascending=(order == Qt.SortOrder.AscendingOrder), inplace=True) else: From 7888df1927d3df0c91b4244f5eb33e849cf993ee Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 6 Nov 2025 09:30:27 +0100 Subject: [PATCH 090/267] app rework --- activity_browser/__init__.py | 3 -- activity_browser/__main__.py | 26 ++++++------ .../actions/activity/activity_delete.py | 2 +- .../activity/activity_duplicate_to_db.py | 2 +- .../actions/activity/activity_new_process.py | 4 +- .../actions/activity/activity_new_product.py | 4 +- .../actions/activity/activity_open.py | 6 +-- .../actions/activity/activity_relink.py | 2 +- .../activity/process_property_modify.py | 4 +- activity_browser/actions/base.py | 4 +- .../actions/calculation_setup/cs_calculate.py | 6 +-- .../actions/calculation_setup/cs_delete.py | 2 +- .../actions/calculation_setup/cs_duplicate.py | 2 +- .../actions/calculation_setup/cs_new.py | 6 +-- .../actions/calculation_setup/cs_open.py | 13 ++---- .../actions/calculation_setup/cs_rename.py | 2 +- .../actions/database/database_delete.py | 4 +- .../actions/database/database_duplicate.py | 2 +- .../database/database_explorer_open.py | 6 +-- .../database/database_export_bw2package.py | 2 +- .../actions/database/database_export_excel.py | 2 +- .../database_import_from_ecoinvent.py | 2 +- .../database/database_importer_bw2package.py | 6 +-- .../database/database_importer_excel.py | 4 +- .../actions/database/database_new.py | 6 +-- .../actions/database/database_open.py | 32 +++++++-------- .../actions/database/database_relink.py | 2 +- .../actions/exchange/exchange_modify.py | 4 -- .../exchange/exchange_uncertainty_modify.py | 4 +- .../actions/metadatastore_open.py | 8 ++-- activity_browser/actions/method/cf_new.py | 4 +- activity_browser/actions/method/cf_remove.py | 4 +- .../actions/method/cf_uncertainty_modify.py | 4 +- .../method/importer/method_importer_bw2io.py | 4 +- .../importer/method_importer_ecoinvent.py | 10 ++--- .../actions/method/method_delete.py | 4 +- .../actions/method/method_duplicate.py | 4 +- .../actions/method/method_meta_modify.py | 4 -- activity_browser/actions/method/method_new.py | 16 +++----- .../actions/method/method_open.py | 8 ++-- .../actions/method/method_rename.py | 6 +-- .../actions/migrations_install.py | 6 +-- .../actions/parameter/parameter_delete.py | 4 +- .../actions/parameter/parameter_new.py | 4 +- .../parameter/parameter_new_automatic.py | 4 +- .../actions/parameter/parameter_rename.py | 4 +- .../parameter/parameter_uncertainty_modify.py | 4 +- .../project/project_create_template.py | 12 +++--- .../actions/project/project_delete.py | 12 +++--- .../actions/project/project_duplicate.py | 6 +-- .../actions/project/project_export.py | 10 ++--- .../actions/project/project_import.py | 12 +++--- .../actions/project/project_manager_open.py | 10 ++--- .../actions/project/project_migrate25.py | 16 ++++---- .../actions/project/project_new.py | 6 +-- .../actions/project/project_new_remote.py | 10 ++--- .../actions/project/project_new_template.py | 10 ++--- .../actions/project/project_switch.py | 12 +++--- activity_browser/actions/pyside_upgrade.py | 6 +-- .../actions/settings_wizard_open.py | 4 +- activity_browser/app/__init__.py | 17 ++++++++ .../{ui/core => app}/application.py | 3 -- .../{layouts => app}/main_window.py | 19 +++------ activity_browser/{layouts => app}/menu_bar.py | 12 +++--- .../{layouts => app}/pages/__init__.py | 0 .../pages/activity_details/__init__.py | 0 .../activity_details/activity_details.py | 12 +++--- .../pages/activity_details/activity_header.py | 4 +- .../pages/activity_details/consumers_tab.py | 0 .../pages/activity_details/data_tab.py | 0 .../pages/activity_details/description_tab.py | 0 .../pages/activity_details/exchanges_tab.py | 0 .../pages/activity_details/graph_tab.py | 0 .../pages/activity_details/parameters_tab.py | 8 ++-- .../pages/calculation_setup/__init__.py | 0 .../calculation_setup/calculation_setup.py | 6 +-- .../functional_unit_section.py | 0 .../impact_category_section.py | 0 .../calculation_setup/scenario_section.py | 8 ++-- .../pages/impact_category_details/__init__.py | 0 .../impact_category_details.py | 8 ++-- .../impact_category_header.py | 0 .../pages/lca_results/LCA_results.py | 8 ++-- .../pages/lca_results/__init__.py | 0 .../pages/lca_results/dialogs.py | 0 .../pages/lca_results/plots.py | 0 .../pages/lca_results/style.py | 0 .../pages/lca_results/tables.py | 0 .../{layouts => app}/pages/metadatastore.py | 0 .../pages/parameters/__init__.py | 0 .../{layouts => app}/pages/parameters/base.py | 0 .../pages/parameters/parameter_models.py | 0 .../pages/parameters/parameter_views.py | 0 .../pages/parameters/parameters.py | 0 .../pages/parameters/parameters_new.py | 12 +++--- .../{layouts => app}/pages/welcome.py | 4 +- .../{layouts => app}/panes/__init__.py | 0 .../panes/calculation_setups.py | 6 +-- .../panes/database_explorer.py | 3 +- .../panes/database_products.py | 6 +-- .../{layouts => app}/panes/databases.py | 12 +++--- .../panes/impact_categories.py | 8 ++-- .../{layouts => app}/panes/project_manager.py | 8 ++-- activity_browser/{ => app}/signals.py | 4 +- activity_browser/bwutils/__init__.py | 2 +- activity_browser/bwutils/metadata/__init__.py | 2 +- activity_browser/bwutils/metadata/loader.py | 41 ++++++++++--------- activity_browser/bwutils/metadata/metadata.py | 11 ++--- activity_browser/bwutils/metadata/updater.py | 4 +- activity_browser/layouts/__init__.py | 4 -- activity_browser/settings.py | 2 +- activity_browser/ui/core/threading.py | 4 +- activity_browser/ui/delegates/formula.py | 4 +- activity_browser/ui/delegates/uncertainty.py | 2 +- .../ui/dialogs/progress_dialog.py | 2 +- activity_browser/ui/dialogs/uncertainty.py | 4 +- activity_browser/ui/icons.py | 1 + activity_browser/ui/web/base.py | 10 ++--- activity_browser/ui/web/navigator.py | 4 +- activity_browser/ui/web/sankey_navigator.py | 4 +- activity_browser/ui/web/tree_navigator.py | 5 +-- activity_browser/ui/widgets/central.py | 2 +- tests/conftest.py | 2 +- 123 files changed, 318 insertions(+), 352 deletions(-) create mode 100644 activity_browser/app/__init__.py rename activity_browser/{ui/core => app}/application.py (99%) rename activity_browser/{layouts => app}/main_window.py (90%) rename activity_browser/{layouts => app}/menu_bar.py (96%) rename activity_browser/{layouts => app}/pages/__init__.py (100%) rename activity_browser/{layouts => app}/pages/activity_details/__init__.py (100%) rename activity_browser/{layouts => app}/pages/activity_details/activity_details.py (93%) rename activity_browser/{layouts => app}/pages/activity_details/activity_header.py (98%) rename activity_browser/{layouts => app}/pages/activity_details/consumers_tab.py (100%) rename activity_browser/{layouts => app}/pages/activity_details/data_tab.py (100%) rename activity_browser/{layouts => app}/pages/activity_details/description_tab.py (100%) rename activity_browser/{layouts => app}/pages/activity_details/exchanges_tab.py (100%) rename activity_browser/{layouts => app}/pages/activity_details/graph_tab.py (100%) rename activity_browser/{layouts => app}/pages/activity_details/parameters_tab.py (98%) rename activity_browser/{layouts => app}/pages/calculation_setup/__init__.py (100%) rename activity_browser/{layouts => app}/pages/calculation_setup/calculation_setup.py (94%) rename activity_browser/{layouts => app}/pages/calculation_setup/functional_unit_section.py (100%) rename activity_browser/{layouts => app}/pages/calculation_setup/impact_category_section.py (100%) rename activity_browser/{layouts => app}/pages/calculation_setup/scenario_section.py (98%) rename activity_browser/{layouts => app}/pages/impact_category_details/__init__.py (100%) rename activity_browser/{layouts => app}/pages/impact_category_details/impact_category_details.py (96%) rename activity_browser/{layouts => app}/pages/impact_category_details/impact_category_header.py (100%) rename activity_browser/{layouts => app}/pages/lca_results/LCA_results.py (99%) rename activity_browser/{layouts => app}/pages/lca_results/__init__.py (100%) rename activity_browser/{layouts => app}/pages/lca_results/dialogs.py (100%) rename activity_browser/{layouts => app}/pages/lca_results/plots.py (100%) rename activity_browser/{layouts => app}/pages/lca_results/style.py (100%) rename activity_browser/{layouts => app}/pages/lca_results/tables.py (100%) rename activity_browser/{layouts => app}/pages/metadatastore.py (100%) rename activity_browser/{layouts => app}/pages/parameters/__init__.py (100%) rename activity_browser/{layouts => app}/pages/parameters/base.py (100%) rename activity_browser/{layouts => app}/pages/parameters/parameter_models.py (100%) rename activity_browser/{layouts => app}/pages/parameters/parameter_views.py (100%) rename activity_browser/{layouts => app}/pages/parameters/parameters.py (100%) rename activity_browser/{layouts => app}/pages/parameters/parameters_new.py (98%) rename activity_browser/{layouts => app}/pages/welcome.py (95%) rename activity_browser/{layouts => app}/panes/__init__.py (100%) rename activity_browser/{layouts => app}/panes/calculation_setups.py (97%) rename activity_browser/{layouts => app}/panes/database_explorer.py (98%) rename activity_browser/{layouts => app}/panes/database_products.py (98%) rename activity_browser/{layouts => app}/panes/databases.py (96%) rename activity_browser/{layouts => app}/panes/impact_categories.py (96%) rename activity_browser/{layouts => app}/panes/project_manager.py (95%) rename activity_browser/{ => app}/signals.py (99%) delete mode 100644 activity_browser/layouts/__init__.py diff --git a/activity_browser/__init__.py b/activity_browser/__init__.py index 37e699f04..4c44c175e 100644 --- a/activity_browser/__init__.py +++ b/activity_browser/__init__.py @@ -14,8 +14,5 @@ except ImportError: import qtpy -from .ui.core.application import application -from .signals import signals - def run_activity_browser(): from .__main__ import run_activity_browser diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index e2fe76730..9ee7ecae5 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -12,8 +12,7 @@ import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("activity.browser.1") -from activity_browser import application -from activity_browser.ui import icons +from activity_browser import app from loguru import logger import platformdirs @@ -89,11 +88,9 @@ def load_modules(self): def load_layout(self): from .ui.widgets import CentralTabWidget - from .layouts import panes, pages, MainWindow + from .app import panes, pages, application from activity_browser.bwutils import AB_metadata - from activity_browser import signals - application.main_window = MainWindow() central_widget = CentralTabWidget(application.main_window) central_widget.addTab(pages.WelcomePage(), "Welcome") central_widget.addTab(pages.ParametersPage(), "Parameters") @@ -109,8 +106,9 @@ def load_settings(self): thread.start() def load_finished(self): - application.main_window.sync() - application.main_window.show() + from activity_browser import app + app.main_window.sync() + app.main_window.show() self.deleteLater() @@ -126,8 +124,8 @@ def run(self): import bw2data, bw2calc, bw2analyzer, bw2io, bw_functional, bw_processing, matrix_utils self.status.emit("Loading Activity Browser") logger.debug("ABLoader: Importing activity_browser") - from activity_browser import actions, layouts, mod, settings, ui, signals - from activity_browser.layouts import panes, pages + from activity_browser import actions, app, mod, settings, ui + from activity_browser.app import panes, pages from activity_browser.ui import core, widgets, web, wizards @@ -168,8 +166,9 @@ def run_activity_browser(): setup_logging() loader = ABLoader() loader.show() - application.set_icon() # setting this here seems to fix the icon not showing sometimes - sys.exit(application.exec_()) + + app.application.set_icon() # setting this here seems to fix the icon not showing sometimes + sys.exit(app.application.exec_()) def run_activity_browser_no_launcher(): @@ -179,12 +178,11 @@ def run_activity_browser_no_launcher(): modules = ModuleThread() modules.run() - from .ui.widgets import MainWindow, CentralTabWidget - from .layouts import panes, pages + from .ui.widgets import CentralTabWidget + from .app import panes, pages, application from activity_browser.bwutils import AB_metadata from activity_browser import signals - application.main_window = MainWindow() central_widget = CentralTabWidget(application.main_window) central_widget.addTab(pages.WelcomePage(), "Welcome") central_widget.addTab(pages.ParametersPage(), "Parameters") diff --git a/activity_browser/actions/activity/activity_delete.py b/activity_browser/actions/activity/activity_delete.py index 80354457b..724c6fdfd 100644 --- a/activity_browser/actions/activity/activity_delete.py +++ b/activity_browser/actions/activity/activity_delete.py @@ -9,7 +9,7 @@ GroupDependency, parameters) -from activity_browser import application +from activity_browser.app import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/activity/activity_duplicate_to_db.py b/activity_browser/actions/activity/activity_duplicate_to_db.py index 3489b5239..bf7a9ae83 100644 --- a/activity_browser/actions/activity/activity_duplicate_to_db.py +++ b/activity_browser/actions/activity/activity_duplicate_to_db.py @@ -5,7 +5,7 @@ import bw2data as bd import bw_functional as bf -from activity_browser import application +from activity_browser.app import application from activity_browser.bwutils import refresh_node from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/activity/activity_new_process.py b/activity_browser/actions/activity/activity_new_process.py index f2b32d5b5..b37b36c47 100644 --- a/activity_browser/actions/activity/activity_new_process.py +++ b/activity_browser/actions/activity/activity_new_process.py @@ -3,7 +3,7 @@ from qtpy.QtWidgets import QDialog import bw2data as bd -from activity_browser import application, bwutils +from activity_browser import app, bwutils from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.dialogs.new_node_dialog import NewNodeDialog @@ -24,7 +24,7 @@ class ActivityNewProcess(ABAction): @exception_dialogs def run(database_name: str): # ask the user to provide a name for the new activity - dialog = NewNodeDialog(application.main_window) + dialog = NewNodeDialog(app.main_window) # if the user cancels, return if dialog.exec_() != QDialog.Accepted: return diff --git a/activity_browser/actions/activity/activity_new_product.py b/activity_browser/actions/activity/activity_new_product.py index 52879a67e..fb5149ccd 100644 --- a/activity_browser/actions/activity/activity_new_product.py +++ b/activity_browser/actions/activity/activity_new_product.py @@ -6,7 +6,7 @@ from bw_functional import Process -from activity_browser import application, bwutils +from activity_browser import app, bwutils from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -49,7 +49,7 @@ def run(activities: list[tuple | int | bd.Node], product_type: str = "product"): for act in activities: assert isinstance(act, Process), "Cannot create new product for non-process type" # Ask the user to provide a name for the new product - dialog = NewProductDialog(act, product_type, application.main_window) + dialog = NewProductDialog(act, product_type, app.main_window) # If the user cancels, skip to the next activity if dialog.exec_() != QtWidgets.QDialog.Accepted: continue diff --git a/activity_browser/actions/activity/activity_open.py b/activity_browser/actions/activity/activity_open.py index 52f9a4a02..422322922 100644 --- a/activity_browser/actions/activity/activity_open.py +++ b/activity_browser/actions/activity/activity_open.py @@ -3,7 +3,7 @@ import bw2data as bd import bw_functional as bf -from activity_browser import signals, bwutils, application +from activity_browser import bwutils, app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -41,7 +41,7 @@ def run(activities: list[tuple | int | bd.Node]): Logs: Warning: If an activity type is not supported. """ - from activity_browser.layouts import pages + from activity_browser.app import pages # Refresh the activity nodes to ensure they are up-to-date activities = [bwutils.refresh_node(activity) for activity in activities] @@ -56,7 +56,7 @@ def run(activities: list[tuple | int | bd.Node]): # Create a details page for the activity page = pages.ActivityDetailsPage(act) - central = application.main_window.centralWidget() + central = app.main_window.centralWidget() # Add the details page to the "Activity Details" group in the central widget central.addToGroup("Activity Details", page) diff --git a/activity_browser/actions/activity/activity_relink.py b/activity_browser/actions/activity/activity_relink.py index c09e4fe3e..64a053ab7 100644 --- a/activity_browser/actions/activity/activity_relink.py +++ b/activity_browser/actions/activity/activity_relink.py @@ -2,7 +2,7 @@ from qtpy import QtCore, QtWidgets -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.bwutils.strategies import relink_activity_exchanges from activity_browser.mod import bw2data as bd diff --git a/activity_browser/actions/activity/process_property_modify.py b/activity_browser/actions/activity/process_property_modify.py index 35b123a62..1d902438d 100644 --- a/activity_browser/actions/activity/process_property_modify.py +++ b/activity_browser/actions/activity/process_property_modify.py @@ -1,6 +1,6 @@ from qtpy import QtWidgets, QtCore -from activity_browser import application, bwutils +from activity_browser import app, bwutils from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -78,7 +78,7 @@ class PropertyDialog(QtWidgets.QDialog): prop: dict | None = None def __init__(self, process: Process): - super().__init__(application.main_window) + super().__init__(app.main_window) self.process = process self.setWindowTitle("Add Property") diff --git a/activity_browser/actions/base.py b/activity_browser/actions/base.py index a9dfbaf6f..1872a2cf5 100644 --- a/activity_browser/actions/base.py +++ b/activity_browser/actions/base.py @@ -1,7 +1,7 @@ from loguru import logger from qtpy import QtCore, QtGui, QtWidgets -from activity_browser import application +from activity_browser import app @@ -51,7 +51,7 @@ def wrapper(*args, **kwargs): if not hasattr(e, "dialog_flag"): setattr(e, "dialog_flag", True) QtWidgets.QMessageBox.critical( - application.main_window, + app.main_window, f"An error occurred: {type(e).__name__}", f"An error occurred, check the logs for more information \n\n {str(e)}", QtWidgets.QMessageBox.Ok, diff --git a/activity_browser/actions/calculation_setup/cs_calculate.py b/activity_browser/actions/calculation_setup/cs_calculate.py index 5634365fc..3c01f73b8 100644 --- a/activity_browser/actions/calculation_setup/cs_calculate.py +++ b/activity_browser/actions/calculation_setup/cs_calculate.py @@ -5,7 +5,7 @@ from qtpy import QtCore, QtWidgets -from activity_browser import application, bwutils +from activity_browser import app, bwutils from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -24,7 +24,7 @@ class CSCalculate(ABAction): @staticmethod @exception_dialogs def run(cs_name: str, scenario_data: pd.DataFrame = None): - from activity_browser.layouts import pages + from activity_browser.app import pages # Check if the calculation setup is complete if cs_name not in bd.calculation_setups: @@ -51,7 +51,7 @@ def run(cs_name: str, scenario_data: pd.DataFrame = None): mc = bwutils.MonteCarloLCA(cs_name) page = pages.LCAResultsPage(cs_name, mlca, contributions, mc) - central = application.main_window.centralWidget() + central = app.main_window.centralWidget() except: dialog.close() raise diff --git a/activity_browser/actions/calculation_setup/cs_delete.py b/activity_browser/actions/calculation_setup/cs_delete.py index bf7ed2bd5..b97e3c179 100644 --- a/activity_browser/actions/calculation_setup/cs_delete.py +++ b/activity_browser/actions/calculation_setup/cs_delete.py @@ -2,7 +2,7 @@ from qtpy import QtWidgets -from activity_browser import application, signals +from activity_browser.app import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/calculation_setup/cs_duplicate.py b/activity_browser/actions/calculation_setup/cs_duplicate.py index f0ba09fc6..14da38b4f 100644 --- a/activity_browser/actions/calculation_setup/cs_duplicate.py +++ b/activity_browser/actions/calculation_setup/cs_duplicate.py @@ -2,7 +2,7 @@ from qtpy import QtWidgets -from activity_browser import application, signals +from activity_browser.app import application, signals from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/calculation_setup/cs_new.py b/activity_browser/actions/calculation_setup/cs_new.py index 8fbd5011b..834b2eab9 100644 --- a/activity_browser/actions/calculation_setup/cs_new.py +++ b/activity_browser/actions/calculation_setup/cs_new.py @@ -4,7 +4,7 @@ import bw2data as bd -from activity_browser import application, actions +from activity_browser import app, actions from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import refresh_node from activity_browser.ui.icons import qicons @@ -51,7 +51,7 @@ def run(name: str = None, # throw error if the name is already present, and return if name in bd.calculation_setups: QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Not possible", "A calculation setup with this name already exists.", ) @@ -80,7 +80,7 @@ def get_cs_name() -> str | None: """ # prompt the user to give a name for the new calculation setup name, ok = QtWidgets.QInputDialog.getText( - application.main_window, + app.main_window, "Create new calculation setup", "Name of new calculation setup:" + " " * 10, ) diff --git a/activity_browser/actions/calculation_setup/cs_open.py b/activity_browser/actions/calculation_setup/cs_open.py index bbf3bb9c8..99c64c356 100644 --- a/activity_browser/actions/calculation_setup/cs_open.py +++ b/activity_browser/actions/calculation_setup/cs_open.py @@ -1,13 +1,8 @@ from loguru import logger -from qtpy import QtWidgets - -from activity_browser import application, signals +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - class CSOpen(ABAction): @@ -16,8 +11,6 @@ class CSOpen(ABAction): @staticmethod @exception_dialogs def run(cs_names: str | list[str]): - from activity_browser.layouts import pages - if isinstance(cs_names, str): cs_names = [cs_names] @@ -26,7 +19,7 @@ def run(cs_names: str | list[str]): logger.warning(f"Calculation setup {cs_name} not found") continue - page = pages.CalculationSetupPage(cs_name) - central = application.main_window.centralWidget() + page = app.pages.CalculationSetupPage(cs_name) + central = app.main_window.centralWidget() central.addToGroup("LCA Setup", page) diff --git a/activity_browser/actions/calculation_setup/cs_rename.py b/activity_browser/actions/calculation_setup/cs_rename.py index 2079afe43..3f775afc5 100644 --- a/activity_browser/actions/calculation_setup/cs_rename.py +++ b/activity_browser/actions/calculation_setup/cs_rename.py @@ -2,7 +2,7 @@ from qtpy import QtWidgets -from activity_browser import application, signals +from activity_browser.app import application, signals from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/database/database_delete.py b/activity_browser/actions/database/database_delete.py index 2e88505ac..1641607d8 100644 --- a/activity_browser/actions/database/database_delete.py +++ b/activity_browser/actions/database/database_delete.py @@ -6,7 +6,7 @@ from bw2data.parameters import Group from bw2data.backends.proxies import ExchangeDataset, Exchanges -from activity_browser import application, settings +from activity_browser import app, settings from activity_browser.bwutils import AB_metadata from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -67,7 +67,7 @@ def run(db_names: List[str]): # ask the user for confirmation QtWidgets.QApplication.restoreOverrideCursor() response = QtWidgets.QMessageBox.question( - application.main_window, build_title(db_names), text + app.main_window, build_title(db_names), text ) # return if the user cancels diff --git a/activity_browser/actions/database/database_duplicate.py b/activity_browser/actions/database/database_duplicate.py index d02858470..c3119b960 100644 --- a/activity_browser/actions/database/database_duplicate.py +++ b/activity_browser/actions/database/database_duplicate.py @@ -5,7 +5,7 @@ import bw2data as bd import bw_functional as bf -from activity_browser import application +from activity_browser.app import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.core.threading import ABThread diff --git a/activity_browser/actions/database/database_explorer_open.py b/activity_browser/actions/database/database_explorer_open.py index fc9dc4a14..4526fb764 100644 --- a/activity_browser/actions/database/database_explorer_open.py +++ b/activity_browser/actions/database/database_explorer_open.py @@ -1,4 +1,4 @@ -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -16,6 +16,6 @@ class DatabaseExplorerOpen(ABAction): @staticmethod @exception_dialogs def run(db_name: str): - from activity_browser.layouts.panes import DatabaseExplorerPane - db_explorer = DatabaseExplorerPane(db_name, application.main_window) + from activity_browser.app.panes import DatabaseExplorerPane + db_explorer = DatabaseExplorerPane(db_name, app.main_window) db_explorer.show() diff --git a/activity_browser/actions/database/database_export_bw2package.py b/activity_browser/actions/database/database_export_bw2package.py index 522ec1712..72e7910c9 100644 --- a/activity_browser/actions/database/database_export_bw2package.py +++ b/activity_browser/actions/database/database_export_bw2package.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets -from activity_browser import application +from activity_browser.app import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui import widgets from activity_browser.bwutils import exporters diff --git a/activity_browser/actions/database/database_export_excel.py b/activity_browser/actions/database/database_export_excel.py index a17225b4e..88d47ad4d 100644 --- a/activity_browser/actions/database/database_export_excel.py +++ b/activity_browser/actions/database/database_export_excel.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets -from activity_browser import application +from activity_browser.app import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui import widgets from activity_browser.bwutils import exporters diff --git a/activity_browser/actions/database/database_import_from_ecoinvent.py b/activity_browser/actions/database/database_import_from_ecoinvent.py index 90586be83..55c722dde 100644 --- a/activity_browser/actions/database/database_import_from_ecoinvent.py +++ b/activity_browser/actions/database/database_import_from_ecoinvent.py @@ -12,7 +12,7 @@ from qtpy import QtWidgets, QtCore from qtpy.QtCore import Signal, SignalInstance -from activity_browser import application, signals +from activity_browser.app import application, signals from activity_browser.ui import widgets, icons from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.bwutils.io.ecoinvent_importer import Ecoinvent7zImporter diff --git a/activity_browser/actions/database/database_importer_bw2package.py b/activity_browser/actions/database/database_importer_bw2package.py index d0bafbb2f..5e059dc5e 100644 --- a/activity_browser/actions/database/database_importer_bw2package.py +++ b/activity_browser/actions/database/database_importer_bw2package.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui import icons, widgets from activity_browser.bwutils.importers import ABPackage @@ -24,7 +24,7 @@ class DatabaseImporterBW2Package(ABAction): def run(cls): # get the path from the user path, _ = QtWidgets.QFileDialog.getOpenFileName( - parent=application.main_window, + parent=app.main_window, caption='Choose .bw2package to import', filter='Brightway2 Database Package (*.bw2package);; All files (*.*)' ) @@ -38,7 +38,7 @@ def run(cls): } # show the import setup dialog - import_dialog = ImportSetup(parent=application.main_window, title="Import Database", context=context) + import_dialog = ImportSetup(parent=app.main_window, title="Import Database", context=context) import_dialog.exec_() diff --git a/activity_browser/actions/database/database_importer_excel.py b/activity_browser/actions/database/database_importer_excel.py index ac66b116d..75f18901c 100644 --- a/activity_browser/actions/database/database_importer_excel.py +++ b/activity_browser/actions/database/database_importer_excel.py @@ -5,7 +5,7 @@ import bw2data as bd -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui import widgets from activity_browser.bwutils.importers import ABExcelImporter @@ -25,7 +25,7 @@ class DatabaseImporterExcel(ABAction): def run(cls): # get the path from the user path, _ = QtWidgets.QFileDialog.getOpenFileName( - parent=application.main_window, + parent=app.main_window, caption='Choose brightway excel database to import', filter='excel spreadsheet (*.xlsx);; All files (*.*)' ) diff --git a/activity_browser/actions/database/database_new.py b/activity_browser/actions/database/database_new.py index a00758b7c..8f2ed2dda 100644 --- a/activity_browser/actions/database/database_new.py +++ b/activity_browser/actions/database/database_new.py @@ -1,6 +1,6 @@ from qtpy import QtWidgets, QtCore -from activity_browser import application, settings, signals +from activity_browser import settings, app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -41,7 +41,7 @@ def run(): if name in bd.databases: QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible", "A database with this name already exists.", ) @@ -88,7 +88,7 @@ def get_new_database_data(cls, window_title="New Database", backend="functional_ - The selected backend type (str). - A boolean indicating whether the dialog was accepted (True) or canceled (False). """ - dialog = cls(window_title, backend, application.main_window) + dialog = cls(window_title, backend, app.main_window) result = dialog.exec_() return dialog.name_input.text(), dialog.backend_dropdown.currentText(), result == QtWidgets.QDialog.Accepted diff --git a/activity_browser/actions/database/database_open.py b/activity_browser/actions/database/database_open.py index 4268c8b59..d55f29856 100644 --- a/activity_browser/actions/database/database_open.py +++ b/activity_browser/actions/database/database_open.py @@ -1,8 +1,6 @@ -from loguru import logger - from qtpy.QtCore import Qt, QEventLoop -from activity_browser import application +from activity_browser import app from activity_browser.ui import widgets from activity_browser.actions.base import ABAction, exception_dialogs @@ -15,26 +13,26 @@ class DatabaseOpen(ABAction): @staticmethod @exception_dialogs def run(database_names: list[str]): - from activity_browser.layouts import panes + from activity_browser.app import panes sibling = DatabaseOpen.find_sibling() for db_name in database_names: - db_pane = panes.DatabaseProductsPane(application.main_window, db_name) - dock_widget = db_pane.getDockWidget(application.main_window) - dock_widget.resize(dock_widget.width(), application.main_window.height() // 2) + db_pane = panes.DatabaseProductsPane(app.main_window, db_name) + dock_widget = db_pane.getDockWidget(app.main_window) + dock_widget.resize(dock_widget.width(), app.main_window.height() // 2) - application.main_window.addDockWidget(DatabaseOpen.get_area(), dock_widget) + app.main_window.addDockWidget(DatabaseOpen.get_area(), dock_widget) if sibling: - application.main_window.tabifyDockWidget(sibling, dock_widget) + app.main_window.tabifyDockWidget(sibling, dock_widget) - application.thread().eventDispatcher().processEvents(QEventLoop.ProcessEventsFlags.AllEvents) + app.application.thread().eventDispatcher().processEvents(QEventLoop.ProcessEventsFlags.AllEvents) dock_widget.raise_() dock_widget.show() else: dock_widget.show() - application.main_window.resizeDocks( + app.main_window.resizeDocks( [dock_widget], [1000], Qt.Vertical @@ -45,14 +43,14 @@ def find_sibling(): """ Find the dockwidget location where the database pane should be opened. """ - from activity_browser.layouts import panes + from activity_browser.app import panes - all_dws = application.main_window.findChildren(widgets.ABDockWidget) - databases_dw = application.main_window.findChild(widgets.ABDockWidget, "dockwidget-databases_pane") + all_dws = app.main_window.findChildren(widgets.ABDockWidget) + databases_dw = app.main_window.findChild(widgets.ABDockWidget, "dockwidget-databases_pane") products_dws = [w for w in all_dws if isinstance(w.widget(), panes.DatabaseProductsPane) and - application.main_window.dockWidgetArea(w) == application.main_window.dockWidgetArea(databases_dw) and + app.main_window.dockWidgetArea(w) == app.main_window.dockWidgetArea(databases_dw) and not w.visibleRegion().isNull() ] return products_dws[0] if products_dws else None @@ -62,5 +60,5 @@ def get_area(): """ Find the dockwidget location where the database pane should be opened. """ - databases_dw = application.main_window.findChild(widgets.ABDockWidget, "dockwidget-databases_pane") - return application.main_window.dockWidgetArea(databases_dw) + databases_dw = app.main_window.findChild(widgets.ABDockWidget, "dockwidget-databases_pane") + return app.main_window.dockWidgetArea(databases_dw) diff --git a/activity_browser/actions/database/database_relink.py b/activity_browser/actions/database/database_relink.py index aee645795..086b64569 100644 --- a/activity_browser/actions/database/database_relink.py +++ b/activity_browser/actions/database/database_relink.py @@ -1,6 +1,6 @@ from qtpy import QtCore, QtWidgets -from activity_browser import application +from activity_browser.app import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.bwutils.strategies import relink_exchanges_existing_db from activity_browser.mod import bw2data as bd diff --git a/activity_browser/actions/exchange/exchange_modify.py b/activity_browser/actions/exchange/exchange_modify.py index 0ab8d1330..9c597e0c0 100644 --- a/activity_browser/actions/exchange/exchange_modify.py +++ b/activity_browser/actions/exchange/exchange_modify.py @@ -1,9 +1,5 @@ -from loguru import logger - -from qtpy.QtWidgets import QMessageBox from bw2data.proxies import ExchangeProxyBase -from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from bw2data.parameters import ActivityParameter diff --git a/activity_browser/actions/exchange/exchange_uncertainty_modify.py b/activity_browser/actions/exchange/exchange_uncertainty_modify.py index 86d23cf03..e1c3b2303 100644 --- a/activity_browser/actions/exchange/exchange_uncertainty_modify.py +++ b/activity_browser/actions/exchange/exchange_uncertainty_modify.py @@ -2,7 +2,7 @@ import bw2data as bd -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.dialogs import UncertaintyDialog @@ -21,7 +21,7 @@ class ExchangeUncertaintyModify(ABAction): def run(exchanges: List[bd.Edge]): ok, uc_dict = UncertaintyDialog.get_uncertainty_dict( - parent=application.main_window, + parent=app.main_window, initial=exchanges[0].uncertainty, ) diff --git a/activity_browser/actions/metadatastore_open.py b/activity_browser/actions/metadatastore_open.py index 59e75f95c..b2418ce96 100644 --- a/activity_browser/actions/metadatastore_open.py +++ b/activity_browser/actions/metadatastore_open.py @@ -1,9 +1,9 @@ from loguru import logger -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -from activity_browser.ui.core.application import global_shortcut +from activity_browser.app.application import global_shortcut @@ -17,7 +17,7 @@ class MetaDataStoreOpen(ABAction): @global_shortcut("Ctrl+Shift+M") @exception_dialogs def run(): - from activity_browser.layouts import pages + from activity_browser.app import pages page = pages.MetaDataStorePage() - central = application.main_window.centralWidget() + central = app.main_window.centralWidget() central.addToGroup("DEBUG", page) diff --git a/activity_browser/actions/method/cf_new.py b/activity_browser/actions/method/cf_new.py index a49950a55..159d19c3d 100644 --- a/activity_browser/actions/method/cf_new.py +++ b/activity_browser/actions/method/cf_new.py @@ -2,7 +2,7 @@ from qtpy import QtWidgets -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -28,7 +28,7 @@ def run(method_name: tuple, keys: List[tuple]): # if there are non-unique keys warn the user that these won't be added if len(unique_keys) < len(keys): QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Duplicate characterization factors", "One or more of these elementary flows already exist within this method. Duplicate flows will not be " "added", diff --git a/activity_browser/actions/method/cf_remove.py b/activity_browser/actions/method/cf_remove.py index 8ba90820e..3db87d594 100644 --- a/activity_browser/actions/method/cf_remove.py +++ b/activity_browser/actions/method/cf_remove.py @@ -2,7 +2,7 @@ from qtpy import QtWidgets -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -22,7 +22,7 @@ class CFRemove(ABAction): def run(method_name: tuple, char_factors: List[tuple]): # ask the user whether they are sure to delete the calculation setup warning = QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Deleting Characterization Factors", f"Are you sure you want to delete {len(char_factors)} CF('s)?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, diff --git a/activity_browser/actions/method/cf_uncertainty_modify.py b/activity_browser/actions/method/cf_uncertainty_modify.py index 92d82a502..420843ef9 100644 --- a/activity_browser/actions/method/cf_uncertainty_modify.py +++ b/activity_browser/actions/method/cf_uncertainty_modify.py @@ -1,7 +1,7 @@ from functools import partial from typing import List -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -25,7 +25,7 @@ def run(cls, method_name: tuple, char_factors: List[tuple]): initial = initial if isinstance(initial, dict) else {} ok, uc_dict = UncertaintyDialog.get_uncertainty_dict( - parent=application.main_window, + parent=app.main_window, initial=initial, ) diff --git a/activity_browser/actions/method/importer/method_importer_bw2io.py b/activity_browser/actions/method/importer/method_importer_bw2io.py index b7d40eab4..40ce2bf41 100644 --- a/activity_browser/actions/method/importer/method_importer_bw2io.py +++ b/activity_browser/actions/method/importer/method_importer_bw2io.py @@ -3,7 +3,7 @@ from qtpy.QtCore import Signal, SignalInstance -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import exception_dialogs from activity_browser.ui import icons, widgets from activity_browser.bwutils.io.ecoinvent_lcia_importer import EcoinventLCIAImporter @@ -25,7 +25,7 @@ class MethodImporterBW2IO(MethodImporterEcoinvent): @exception_dialogs def run(cls): # initialize the import thread, setting needed attributes - extract_thread = ExtractMethodsThread(application) + extract_thread = ExtractMethodsThread(app.application) extract_thread.loaded.connect(cls.write_database) # show progress dialog for importing the excel diff --git a/activity_browser/actions/method/importer/method_importer_ecoinvent.py b/activity_browser/actions/method/importer/method_importer_ecoinvent.py index 9d19059d6..684649aca 100644 --- a/activity_browser/actions/method/importer/method_importer_ecoinvent.py +++ b/activity_browser/actions/method/importer/method_importer_ecoinvent.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets, QtCore from qtpy.QtCore import Signal, SignalInstance -from activity_browser import application +from activity_browser import app from activity_browser.mod import bw2data as bd from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui import icons, widgets @@ -25,7 +25,7 @@ class MethodImporterEcoinvent(ABAction): def run(cls): # get the path from the user path, _ = QtWidgets.QFileDialog.getOpenFileName( - parent=application.main_window, + parent=app.main_window, caption='Choose ecoinvent methods excel to import', filter='excel spreadsheet (*.xlsx);; All files (*.*)' ) @@ -33,7 +33,7 @@ def run(cls): return # initialize the import thread, setting needed attributes - extract_thread = ExtractExcelThread(application) + extract_thread = ExtractExcelThread(app.application) extract_thread.path = path extract_thread.loaded.connect(cls.write_database) @@ -46,12 +46,12 @@ def run(cls): @staticmethod def write_database(importer: EcoinventLCIAImporter): # show the import setup dialog - import_dialog = ImportSetupDialog(importer, application.main_window) + import_dialog = ImportSetupDialog(importer, app.main_window) if import_dialog.exec_() == QtWidgets.QDialog.Rejected: return # setup the importer thread - importer_thread = ImportExcelThread(application) + importer_thread = ImportExcelThread(app.application) importer_thread.importer = importer importer_thread.biosphere_name = import_dialog.biosphere_name importer_thread.prepend = import_dialog.prepend diff --git a/activity_browser/actions/method/method_delete.py b/activity_browser/actions/method/method_delete.py index 934477b17..f4f4093b5 100644 --- a/activity_browser/actions/method/method_delete.py +++ b/activity_browser/actions/method/method_delete.py @@ -4,7 +4,7 @@ from qtpy import QtWidgets -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -43,7 +43,7 @@ def run(methods: List[tuple]): # warn the user about the pending deletion warning = QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Deleting Method", warning_text, QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, diff --git a/activity_browser/actions/method/method_duplicate.py b/activity_browser/actions/method/method_duplicate.py index cef76730b..1068b7ff4 100644 --- a/activity_browser/actions/method/method_duplicate.py +++ b/activity_browser/actions/method/method_duplicate.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -39,7 +39,7 @@ def run(methods: List[tuple], level: str = None): # retrieve the new name(s) from the user and return if canceled dialog = TupleNameDialog.get_combined_name( - application.main_window, + app.main_window, "Impact category name", "Combined name:", selected_method, diff --git a/activity_browser/actions/method/method_meta_modify.py b/activity_browser/actions/method/method_meta_modify.py index 1fadfc155..244caa371 100644 --- a/activity_browser/actions/method/method_meta_modify.py +++ b/activity_browser/actions/method/method_meta_modify.py @@ -1,9 +1,5 @@ -from typing import List from loguru import logger -from qtpy import QtWidgets - -from activity_browser import application from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/method/method_new.py b/activity_browser/actions/method/method_new.py index 7f86a2e1d..e65cd0c70 100644 --- a/activity_browser/actions/method/method_new.py +++ b/activity_browser/actions/method/method_new.py @@ -2,16 +2,12 @@ from qtpy import QtWidgets -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons from activity_browser.ui import dialogs -from .method_open import MethodOpen - - - class MethodNew(ABAction): """ @@ -37,7 +33,7 @@ class MethodNew(ABAction): @exception_dialogs def run(): # Open dialog to get new method name - dialog = dialogs.ABListEditDialog(("New Impact Category",), parent=application.main_window) + dialog = dialogs.ABListEditDialog(("New Impact Category",), parent=app.main_window) dialog.setWindowTitle("New Impact Category") if dialog.exec_() != QtWidgets.QDialog.Accepted: @@ -48,7 +44,7 @@ def run(): # Validate new name if len(new_name) == 0: QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Invalid Name", "Impact category name cannot be empty.", ) @@ -56,7 +52,7 @@ def run(): if new_name in bd.methods: QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Name Already Exists", f"An impact category with the name '{' | '.join(new_name)}' already exists.", ) @@ -70,10 +66,10 @@ def run(): logger.info(f"Created new impact category: {new_name}") # Open the method in the ImpactCategoryDetails page - from activity_browser.layouts import pages + from activity_browser.app import pages page = pages.ImpactCategoryDetailsPage(new_name) - central = application.main_window.centralWidget() + central = app.main_window.centralWidget() central.addToGroup("Characterization Factors", page) # Set the page to edit mode diff --git a/activity_browser/actions/method/method_open.py b/activity_browser/actions/method/method_open.py index a0580d8d7..e61ef0d5f 100644 --- a/activity_browser/actions/method/method_open.py +++ b/activity_browser/actions/method/method_open.py @@ -1,8 +1,6 @@ from typing import List -from qtpy import QtWidgets, QtCore - -from activity_browser import signals, application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -20,11 +18,11 @@ class MethodOpen(ABAction): @staticmethod @exception_dialogs def run(method_names: List[tuple]): - from activity_browser.layouts import pages + from activity_browser.app import pages for name in method_names: page = pages.ImpactCategoryDetailsPage(name) - central = application.main_window.centralWidget() + central = app.main_window.centralWidget() central.addToGroup("Characterization Factors", page) diff --git a/activity_browser/actions/method/method_rename.py b/activity_browser/actions/method/method_rename.py index 4156c10ee..8c91296b1 100644 --- a/activity_browser/actions/method/method_rename.py +++ b/activity_browser/actions/method/method_rename.py @@ -5,7 +5,7 @@ import bw2data as bd -from activity_browser import application, signals +from activity_browser import app from activity_browser.ui import dialogs from activity_browser.actions.base import ABAction, exception_dialogs @@ -50,7 +50,7 @@ def run(method_name: tuple[str] | list[tuple[str]]): dialog = dialogs.ABListEditDialog( method_name, title="Rename Impact Category", - parent=application.main_window, + parent=app.main_window, ) # execute the dialog and check for acceptance @@ -77,7 +77,7 @@ def run(method_name: tuple[str] | list[tuple[str]]): # this should not happen like this, as the model and therefore signals should be handled declaritavely, # but since method renaming is not native to bw2data we have to do it manually here - signals.method.renamed.emit(method_name, new_name) + app.signals.method.renamed.emit(method_name, new_name) # deregister old method method.deregister() diff --git a/activity_browser/actions/migrations_install.py b/activity_browser/actions/migrations_install.py index 32f1d8c7c..4d0687c7f 100644 --- a/activity_browser/actions/migrations_install.py +++ b/activity_browser/actions/migrations_install.py @@ -1,6 +1,6 @@ from qtpy import QtWidgets -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui import icons from activity_browser.mod.bw2io.migrations import ab_create_core_migrations @@ -23,12 +23,12 @@ def update_dialog_slot(progress: int, label: str): dialog.setLabelText(label) - dialog = QtWidgets.QProgressDialog(application.main_window) + dialog = QtWidgets.QProgressDialog(app.main_window) dialog.setWindowTitle("Installing migrations") dialog.setMaximum(100) dialog.setCancelButton(None) - thread = MigrationsInstallThread(application) + thread = MigrationsInstallThread(app.application) thread.status.connect(update_dialog_slot) diff --git a/activity_browser/actions/parameter/parameter_delete.py b/activity_browser/actions/parameter/parameter_delete.py index f46f4259d..7a6823bc6 100644 --- a/activity_browser/actions/parameter/parameter_delete.py +++ b/activity_browser/actions/parameter/parameter_delete.py @@ -1,6 +1,6 @@ from typing import Any -from activity_browser import signals +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from bw2data import get_activity from bw2data.parameters import (ActivityParameter, Group, @@ -56,4 +56,4 @@ def run(parameter: Any): # No fire when everything is still fresh after recalculation, so need to fire manually to be sure everything is # updated correctly. - signals.parameter.recalculated.emit() + app.signals.parameter.recalculated.emit() diff --git a/activity_browser/actions/parameter/parameter_new.py b/activity_browser/actions/parameter/parameter_new.py index 7aaf132a8..f6eac5152 100644 --- a/activity_browser/actions/parameter/parameter_new.py +++ b/activity_browser/actions/parameter/parameter_new.py @@ -2,7 +2,7 @@ from qtpy import QtCore, QtGui, QtWidgets -from activity_browser import actions, application +from activity_browser import actions, app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import commontasks as bc from activity_browser.mod import bw2data as bd @@ -35,7 +35,7 @@ class ParameterNew(ABAction): @exception_dialogs def run(activity_key: Tuple[str, str]): # instantiate the ParameterWizard - wizard = ParameterWizard(activity_key, application.main_window) + wizard = ParameterWizard(activity_key, app.main_window) # return if the wizard is canceled if wizard.exec_() != QtWidgets.QWizard.Accepted: diff --git a/activity_browser/actions/parameter/parameter_new_automatic.py b/activity_browser/actions/parameter/parameter_new_automatic.py index bc7ac671c..b60ff432b 100644 --- a/activity_browser/actions/parameter/parameter_new_automatic.py +++ b/activity_browser/actions/parameter/parameter_new_automatic.py @@ -3,7 +3,7 @@ from peewee import IntegrityError from qtpy import QtWidgets -from activity_browser import application +from activity_browser import app from activity_browser.bwutils import refresh_node from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd @@ -31,7 +31,7 @@ def run(activities: List[tuple | int | bd.Node]): if act.get("type", "process") not in bd.labels.lci_node_types: issue = f"Activity must be 'process' type, '{act.get('name')}' is type '{act.get('type')}'." QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Not allowed", issue, QtWidgets.QMessageBox.Ok, diff --git a/activity_browser/actions/parameter/parameter_rename.py b/activity_browser/actions/parameter/parameter_rename.py index 8328bf024..7932abb02 100644 --- a/activity_browser/actions/parameter/parameter_rename.py +++ b/activity_browser/actions/parameter/parameter_rename.py @@ -2,7 +2,7 @@ from bw2data.parameters import ParameterBase, parameters -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.bwutils import Parameter, refresh_parameter @@ -38,7 +38,7 @@ def run(parameter: tuple | Parameter | ParameterBase, new_name: str = None): @staticmethod def get_new_name(parameter: Parameter): new_name, ok = QtWidgets.QInputDialog.getText( - application.main_window, + app.main_window, "Rename parameter", f"Rename parameter '{parameter.name}' to:", ) diff --git a/activity_browser/actions/parameter/parameter_uncertainty_modify.py b/activity_browser/actions/parameter/parameter_uncertainty_modify.py index 209872fa1..956acb164 100644 --- a/activity_browser/actions/parameter/parameter_uncertainty_modify.py +++ b/activity_browser/actions/parameter/parameter_uncertainty_modify.py @@ -3,7 +3,7 @@ import bw2data as bd from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser import application +from activity_browser import app from activity_browser.ui.dialogs import UncertaintyDialog from activity_browser.ui.icons import qicons @@ -24,7 +24,7 @@ def run(parameter: Any, uncertainty_dict: dict=None) -> None: initial = parameter.dict.copy() if "uncertainty type" in parameter.dict else None ok, uncertainty_dict = UncertaintyDialog.get_uncertainty_dict( - parent=application.main_window, + parent=app.main_window, initial=initial, ) diff --git a/activity_browser/actions/project/project_create_template.py b/activity_browser/actions/project/project_create_template.py index 9a4b8104b..14d92bd4e 100644 --- a/activity_browser/actions/project/project_create_template.py +++ b/activity_browser/actions/project/project_create_template.py @@ -6,7 +6,7 @@ from qtpy import QtWidgets, QtCore import platformdirs -from activity_browser import application +from activity_browser import app from activity_browser.mod import bw2data as bd from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.core.threading import ABThread @@ -19,7 +19,7 @@ class ProjectCreateTemplate(ABAction): ABAction to export the current project. Prompts the user to return a save-file location. And then start a thread to package the project and save it there. Saving code copied from bw2data.backup. """ - icon = application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) + icon = app.application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) text = "Create template for project" tool_tip = "Export project to file" @@ -32,7 +32,7 @@ def run(project_name: str = None, parent=None): # get target path from the user template_name, ok = QtWidgets.QInputDialog.getText( - parent if parent else application.main_window, + parent if parent else app.main_window, "Create template from project", f"Creating new template from project ({project_name}):" + " " * 10, @@ -49,7 +49,7 @@ def run(project_name: str = None, parent=None): if os.path.exists(template_path): QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible.", "A template with this name already exists.", ) @@ -57,7 +57,7 @@ def run(project_name: str = None, parent=None): # setup dialog progress = QtWidgets.QProgressDialog( - parent=parent if parent else application.main_window, + parent=parent if parent else app.main_window, labelText="Creating template", maximum=0 ) @@ -69,7 +69,7 @@ def run(project_name: str = None, parent=None): progress.resize(400, 100) progress.show() - thread = TemplateThread(application) + thread = TemplateThread(app.application) setattr(thread, "save_path", template_path) setattr(thread, "project_name", project_name) thread.finished.connect(lambda: progress.deleteLater()) diff --git a/activity_browser/actions/project/project_delete.py b/activity_browser/actions/project/project_delete.py index b87ab3598..038efb1d6 100644 --- a/activity_browser/actions/project/project_delete.py +++ b/activity_browser/actions/project/project_delete.py @@ -6,7 +6,7 @@ from bw2data.project import ProjectDataset from bw2data.utils import safe_filename -from activity_browser import settings, application +from activity_browser import settings, app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -45,7 +45,7 @@ class ProjectDelete(ABAction): @staticmethod @exception_dialogs - def run(project_names: [str] = None): + def run(project_names: list[str] = None): if project_names is None: # get the current project project_names = [bd.projects.current] @@ -56,14 +56,14 @@ def run(project_names: [str] = None): # if it's the startup project: reject deletion and inform user if settings.ab_settings.startup_project in project_names: QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible", "Can't delete the startup project. Please select another startup project in the settings first.", ) return # open a delete dialog for the user to confirm, return if user rejects - delete_dialog = ProjectDeletionDialog(project_names, application.main_window) + delete_dialog = ProjectDeletionDialog(project_names, app.main_window) if delete_dialog.exec_() != ProjectDeletionDialog.Accepted: return @@ -76,7 +76,7 @@ def run(project_names: [str] = None): # inform the user of successful deletion QtWidgets.QMessageBox.information( - application.main_window, "Project(s) deleted", "Project(s) successfully deleted" + app.main_window, "Project(s) deleted", "Project(s) successfully deleted" ) @staticmethod @@ -94,7 +94,7 @@ def delete_project(name: str, delete_dir: bool): class ProjectDeletionDialog(QtWidgets.QDialog): - def __init__(self, projects: [str], parent=None): + def __init__(self, projects: list[str], parent=None): super().__init__(parent) self.title = "Confirm project deletion" diff --git a/activity_browser/actions/project/project_duplicate.py b/activity_browser/actions/project/project_duplicate.py index d22b19542..82f7fef2e 100644 --- a/activity_browser/actions/project/project_duplicate.py +++ b/activity_browser/actions/project/project_duplicate.py @@ -1,6 +1,6 @@ from qtpy import QtWidgets -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -42,7 +42,7 @@ def run(name: str = None): name = bd.projects.current new_name, ok = QtWidgets.QInputDialog.getText( - application.main_window, + app.main_window, "Duplicate current project", f"Duplicate project ({name}) to new name:" + " " * 10, @@ -53,7 +53,7 @@ def run(name: str = None): if new_name in bd.projects: QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible.", "A project with this name already exists.", ) diff --git a/activity_browser/actions/project/project_export.py b/activity_browser/actions/project/project_export.py index 3a7e5ac5c..06c3cbc83 100644 --- a/activity_browser/actions/project/project_export.py +++ b/activity_browser/actions/project/project_export.py @@ -8,7 +8,7 @@ import bw2data as bd from bw2data.project import ProjectDataset -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.core.threading import ABThread @@ -20,7 +20,7 @@ class ProjectExport(ABAction): ABAction to export the current project. Prompts the user to return a save-file location. And then start a thread to package the project and save it there. Saving code copied from bw2data.backup. """ - icon = application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) + icon = app.application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) text = "&Export this project..." tool_tip = "Export project to file" @@ -33,7 +33,7 @@ def run(project_name: str = None): # get target path from the user save_path, save_type = QtWidgets.QFileDialog.getSaveFileName( - parent=application.main_window, + parent=app.main_window, caption="Choose where", dir=os.path.expanduser(f"~/{project_name}.tar.gz"), filter="Tar-file (*.tar.gz)" @@ -43,7 +43,7 @@ def run(project_name: str = None): # setup dialog progress = QtWidgets.QProgressDialog( - parent=application.main_window, + parent=app.main_window, labelText="Exporting project", maximum=0 ) @@ -55,7 +55,7 @@ def run(project_name: str = None): progress.resize(400, 100) progress.show() - thread = ExportThread(application) + thread = ExportThread(app.application) setattr(thread, "save_path", save_path) setattr(thread, "project_name", project_name) thread.finished.connect(lambda: progress.deleteLater()) diff --git a/activity_browser/actions/project/project_import.py b/activity_browser/actions/project/project_import.py index f70a81a64..36fac12b4 100644 --- a/activity_browser/actions/project/project_import.py +++ b/activity_browser/actions/project/project_import.py @@ -7,7 +7,7 @@ from qtpy import QtWidgets, QtCore from bw2io import backup -from activity_browser import application +from activity_browser import app from activity_browser.mod import bw2data as bd from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -34,7 +34,7 @@ def run(cls): # get the path from the user path, _ = QtWidgets.QFileDialog.getOpenFileName( - parent=application.main_window, + parent=app.main_window, caption='Choose project file to import', filter='Tar archive (*.tar.gz);; All files (*.*)' ) @@ -46,7 +46,7 @@ def run(cls): # get a new project name from the user: while True: project_name, _ = QtWidgets.QInputDialog.getText( - application.main_window, + app.main_window, 'Choose project name', 'Choose a name for your project', text=suggestion @@ -57,7 +57,7 @@ def run(cls): if project_name in bd.projects: # this name already exists, inform user and ask again. QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible.", "A project with this name already exists." ) @@ -65,7 +65,7 @@ def run(cls): # setup dialog progress = QtWidgets.QProgressDialog( - parent=application.main_window, + parent=app.main_window, labelText="Importing project", maximum=0 ) @@ -78,7 +78,7 @@ def run(cls): progress.show() # setup the import - thread = ImportThread(application) + thread = ImportThread(app.application) setattr(thread, "path", path) setattr(thread, "project_name", project_name) diff --git a/activity_browser/actions/project/project_manager_open.py b/activity_browser/actions/project/project_manager_open.py index 2d70a4e66..e09c6c56d 100644 --- a/activity_browser/actions/project/project_manager_open.py +++ b/activity_browser/actions/project/project_manager_open.py @@ -1,6 +1,6 @@ from qtpy import QtCore -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -18,9 +18,9 @@ class ProjectManagerOpen(ABAction): @staticmethod @exception_dialogs def run(): - from activity_browser.layouts.panes import ProjectManagerPane + from activity_browser.app.panes import ProjectManagerPane - project_manager = ProjectManagerPane(application.main_window) - application.main_window.addDockWidget( + project_manager = ProjectManagerPane(app.main_window) + app.main_window.addDockWidget( QtCore.Qt.LeftDockWidgetArea, - project_manager.getDockWidget(application.main_window)) + project_manager.getDockWidget(app.main_window)) diff --git a/activity_browser/actions/project/project_migrate25.py b/activity_browser/actions/project/project_migrate25.py index 7367dbb49..a1e5f2902 100644 --- a/activity_browser/actions/project/project_migrate25.py +++ b/activity_browser/actions/project/project_migrate25.py @@ -5,7 +5,7 @@ import bw2data as bd import pandas as pd -from activity_browser import application, signals +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import AB_metadata from activity_browser.ui.icons import qicons @@ -31,7 +31,7 @@ def run(name: str = None): if name is None: name = bd.projects.current - dialog = MigrateDialog(name, application.main_window) + dialog = MigrateDialog(name, app.main_window) dialog.exec_() if dialog.result() == dialog.DialogCode.Rejected: @@ -42,7 +42,7 @@ def run(name: str = None): # setup dialog progress = QtWidgets.QProgressDialog( - parent=application.main_window, + parent=app.main_window, labelText="Migrating project, this may take a while...", maximum=0 ) @@ -54,7 +54,7 @@ def run(name: str = None): progress.resize(400, 100) progress.show() - thread = MigrateThread(application) + thread = MigrateThread(app.application) thread.finished.connect(lambda: progress.deleteLater()) thread.start() thread.connect_progress_dialog(progress) @@ -118,16 +118,16 @@ def pre_process_methods(cls): df = df.merge(AB_metadata.dataframe["id"], left_on=["database", "code"], right_index=True) - signals.method.blockSignals(True) - signals.meta.blockSignals(True) + app.signals.method.blockSignals(True) + app.signals.meta.blockSignals(True) for name in tqdm(df["method"].unique(), desc="Pre-processing methods", unit="method", total=len(df["method"].unique())): method_df = df[df["method"] == name][["id", "value"]] method_list = list(method_df.itertuples(index=False, name=None)) bd.Method(name).write(method_list, process=False) - signals.method.blockSignals(False) - signals.meta.blockSignals(False) + app.signals.method.blockSignals(False) + app.signals.meta.blockSignals(False) return diff --git a/activity_browser/actions/project/project_new.py b/activity_browser/actions/project/project_new.py index 0d87b8953..d7e3f6d79 100644 --- a/activity_browser/actions/project/project_new.py +++ b/activity_browser/actions/project/project_new.py @@ -1,6 +1,6 @@ from qtpy import QtWidgets -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -29,7 +29,7 @@ class ProjectNew(ABAction): @exception_dialogs def run(): name, ok = QtWidgets.QInputDialog.getText( - application.main_window, + app.main_window, "Create new project", "Name of new project:" + " " * 25, ) @@ -39,7 +39,7 @@ def run(): if name in bd.projects: QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible.", "A project with this name already exists.", ) diff --git a/activity_browser/actions/project/project_new_remote.py b/activity_browser/actions/project/project_new_remote.py index 90eebb0d4..ac3d727ec 100644 --- a/activity_browser/actions/project/project_new_remote.py +++ b/activity_browser/actions/project/project_new_remote.py @@ -2,7 +2,7 @@ import bw2data as bd -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod.bw2io import remote from activity_browser.ui.icons import qicons @@ -24,7 +24,7 @@ class ProjectNewRemote(ABAction): @exception_dialogs def run(project_key: str): name, ok = QtWidgets.QInputDialog.getText( - application.main_window, + app.main_window, "Create project from remote", "Name of new project:" + " " * 25, ) @@ -34,16 +34,16 @@ def run(project_key: str): if name in bd.projects: QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible.", "A project with this name already exists.", ) return - thread = InstallThread(application) + thread = InstallThread(app.application) thread.start(project_key, name) - dialog = MigrateDialog(application.main_window) + dialog = MigrateDialog(app.main_window) dialog.show() thread.finished.connect(dialog.close) diff --git a/activity_browser/actions/project/project_new_template.py b/activity_browser/actions/project/project_new_template.py index 198579d90..79544f3a6 100644 --- a/activity_browser/actions/project/project_new_template.py +++ b/activity_browser/actions/project/project_new_template.py @@ -4,7 +4,7 @@ import bw2data as bd from bw2io import backup -from activity_browser import application, utils +from activity_browser import app, utils from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.core.threading import ABThread @@ -30,7 +30,7 @@ def run(template_key: str): raise ValueError(f"Template key '{template_key}' not found.") name, ok = QtWidgets.QInputDialog.getText( - application.main_window, + app.main_window, "Create project from template", "Name of new project:" + " " * 25, ) @@ -40,7 +40,7 @@ def run(template_key: str): if name in bd.projects: QtWidgets.QMessageBox.information( - application.main_window, + app.main_window, "Not possible.", "A project with this name already exists.", ) @@ -48,7 +48,7 @@ def run(template_key: str): # setup dialog progress = QtWidgets.QProgressDialog( - parent=application.main_window, + parent=app.main_window, labelText="Creating project from template", maximum=0 ) @@ -61,7 +61,7 @@ def run(template_key: str): progress.show() # setup the import - thread = ImportThread(application) + thread = ImportThread(app.application) setattr(thread, "path", utils.get_templates()[template_key]) setattr(thread, "project_name", name) diff --git a/activity_browser/actions/project/project_switch.py b/activity_browser/actions/project/project_switch.py index 738179ccb..cb09c50b1 100644 --- a/activity_browser/actions/project/project_switch.py +++ b/activity_browser/actions/project/project_switch.py @@ -5,7 +5,7 @@ import bw2data as bd -from activity_browser import application, signals +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from .project_migrate25 import ProjectMigrate25 @@ -43,9 +43,9 @@ def run(project_name: str): logger.debug(f"Brightway2 already selected: {project_name}") return - dialog = ProjectChangeDialog(project_name, application.main_window) + dialog = ProjectChangeDialog(project_name, app.main_window) dialog.show() - application.thread().eventDispatcher().processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + app.application.thread().eventDispatcher().processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) # switch to the new project, don't auto update to brightway25 bd.projects.set_current(project_name, update=False) @@ -64,7 +64,7 @@ def run(project_name: str): @staticmethod def set_warning_bar(): - application.main_window.addToolBar(ProjectWarningBar()) + app.main_window.addToolBar(ProjectWarningBar()) class ProjectChangeDialog(QtWidgets.QDialog): @@ -89,7 +89,7 @@ def __init__(self, parent=None): height = warning_label.minimumSizeHint().height() warning_icon = QtWidgets.QLabel(self) - qicon = application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) + qicon = app.application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) pixmap = qicon.pixmap(height, height) warning_icon.setPixmap(pixmap) @@ -100,7 +100,7 @@ def __init__(self, parent=None): self.addWidget(warning_label) self.addWidget(migrate_label) - signals.project.changed.connect(self.deleteLater) + app.signals.project.changed.connect(self.deleteLater) def contextMenuEvent(self, event): return None diff --git a/activity_browser/actions/pyside_upgrade.py b/activity_browser/actions/pyside_upgrade.py index 124f056fd..e3038ee89 100644 --- a/activity_browser/actions/pyside_upgrade.py +++ b/activity_browser/actions/pyside_upgrade.py @@ -4,7 +4,7 @@ import subprocess import time -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui import icons @@ -36,7 +36,7 @@ def update_dialog_slot(progress: int, label: str): assert cls.in_conda(), "Not inside a Conda environment" # setup a progress dialog to show the user we're doing something - dialog = QtWidgets.QProgressDialog(application.main_window) + dialog = QtWidgets.QProgressDialog(app.main_window) dialog.setWindowTitle("Upgrading GUI back-end") dialog.setMaximum(0) dialog.setCancelButton(None) @@ -46,7 +46,7 @@ def update_dialog_slot(progress: int, label: str): lbl.setWordWrap(True) # initialize thread and connect signals - thread = PySideUpgradeThread(application) + thread = PySideUpgradeThread(app.application) thread.status.connect(update_dialog_slot) thread.exit.connect(sys.exit) diff --git a/activity_browser/actions/settings_wizard_open.py b/activity_browser/actions/settings_wizard_open.py index 0b3e39713..f2b1e026e 100644 --- a/activity_browser/actions/settings_wizard_open.py +++ b/activity_browser/actions/settings_wizard_open.py @@ -1,4 +1,4 @@ -from activity_browser import application +from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.wizards.settings_wizard import SettingsWizard @@ -13,4 +13,4 @@ class SettingsWizardOpen(ABAction): @staticmethod @exception_dialogs def run(): - SettingsWizard(application.main_window).show() + SettingsWizard(app.main_window).show() diff --git a/activity_browser/app/__init__.py b/activity_browser/app/__init__.py new file mode 100644 index 000000000..8bf6ef6dc --- /dev/null +++ b/activity_browser/app/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +__all__ = ["panes", "pages", "application", "signals", "metadata", "main_window"] + +from activity_browser.bwutils import MetaDataStore + +from .main_window import MainWindow +from .application import ABApplication +from .signals import ABSignals + +application = ABApplication() + +signals = ABSignals() + +main_window = MainWindow() +application.main_window = main_window + +metadata = MetaDataStore() diff --git a/activity_browser/ui/core/application.py b/activity_browser/app/application.py similarity index 99% rename from activity_browser/ui/core/application.py rename to activity_browser/app/application.py index c7bb0238e..90b0d58c6 100644 --- a/activity_browser/ui/core/application.py +++ b/activity_browser/app/application.py @@ -109,6 +109,3 @@ def decorator(func): return decorator _global_shortcuts = {} - - -application = ABApplication() diff --git a/activity_browser/layouts/main_window.py b/activity_browser/app/main_window.py similarity index 90% rename from activity_browser/layouts/main_window.py rename to activity_browser/app/main_window.py index 6f223fb24..1a82067eb 100644 --- a/activity_browser/layouts/main_window.py +++ b/activity_browser/app/main_window.py @@ -1,21 +1,14 @@ -import pickle -from loguru import logger - -from qtpy import QtCore, QtWidgets, QtGui +from qtpy import QtCore, QtWidgets import bw2data as bd - -from activity_browser import signals, application -from activity_browser.ui import icons - -from activity_browser.layouts.menu_bar import MenuBar - - +from activity_browser import app class MainWindow(QtWidgets.QMainWindow): def __init__(self, parent=None): + from activity_browser.app.menu_bar import MenuBar + super().__init__(parent) self.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) @@ -47,7 +40,7 @@ def sync(self): Args: self: The instance of the MainWindow class. """ - from activity_browser.layouts import panes + from activity_browser.app import panes # Clear all existing panes in the main window self.clearPanes() @@ -85,7 +78,7 @@ def sync(self): def connect_signals(self): # Keyboard shortcuts - signals.project.changed.connect(self.sync) + app.signals.project.changed.connect(self.sync) def clearPanes(self): for pane in self.panes(): diff --git a/activity_browser/layouts/menu_bar.py b/activity_browser/app/menu_bar.py similarity index 96% rename from activity_browser/layouts/menu_bar.py rename to activity_browser/app/menu_bar.py index c9f02de81..759f5d1e7 100644 --- a/activity_browser/layouts/menu_bar.py +++ b/activity_browser/app/menu_bar.py @@ -5,7 +5,7 @@ from qtpy import QtGui, QtWidgets from qtpy.QtCore import QSize, QUrl -from activity_browser import actions, signals, utils, application +from activity_browser import actions, utils, app from ..ui.icons import qicons @@ -138,8 +138,8 @@ def __init__(self, parent=None) -> None: self.addAction(self.new_cs_action) self.addSeparator() - signals.project.changed.connect(self.sync) - signals.meta.calculation_setups_changed.connect(self.sync) + app.signals.project.changed.connect(self.sync) + app.signals.meta.calculation_setups_changed.connect(self.sync) def sync(self): self.cs_actions.clear() @@ -163,7 +163,7 @@ def __init__(self, parent=None) -> None: qicons.ab, "&About Activity Browser", self.about ) self.addAction( - "&About Qt", lambda: QtWidgets.QMessageBox.aboutQt(application.main_window) + "&About Qt", lambda: QtWidgets.QMessageBox.aboutQt(app.main_window) ) self.addAction( qicons.question, "&Get help on the wiki", self.open_wiki @@ -187,7 +187,7 @@ def about(self): """ # set up the window - about_window = QtWidgets.QMessageBox(parent=application.main_window) + about_window = QtWidgets.QMessageBox(parent=app.main_window) about_window.setWindowTitle("About the Activity Browser") about_window.setIconPixmap(qicons.ab.pixmap(QSize(150, 150))) about_window.setText(text) @@ -245,7 +245,7 @@ def populate(self): action = QtWidgets.QAction(proj.name, self) action.setData(proj.name) action.setIcon( - application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) if not bw_25 else qicons.empty) + app.application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) if not bw_25 else qicons.empty) self.addAction(action) diff --git a/activity_browser/layouts/pages/__init__.py b/activity_browser/app/pages/__init__.py similarity index 100% rename from activity_browser/layouts/pages/__init__.py rename to activity_browser/app/pages/__init__.py diff --git a/activity_browser/layouts/pages/activity_details/__init__.py b/activity_browser/app/pages/activity_details/__init__.py similarity index 100% rename from activity_browser/layouts/pages/activity_details/__init__.py rename to activity_browser/app/pages/activity_details/__init__.py diff --git a/activity_browser/layouts/pages/activity_details/activity_details.py b/activity_browser/app/pages/activity_details/activity_details.py similarity index 93% rename from activity_browser/layouts/pages/activity_details/activity_details.py rename to activity_browser/app/pages/activity_details/activity_details.py index 62546db1f..6c7aec4b3 100644 --- a/activity_browser/layouts/pages/activity_details/activity_details.py +++ b/activity_browser/app/pages/activity_details/activity_details.py @@ -4,7 +4,7 @@ import bw2data as bd -from activity_browser import signals, bwutils +from activity_browser import app, bwutils from activity_browser.ui import widgets from .activity_header import ActivityHeader @@ -103,11 +103,11 @@ def connect_signals(self): """ Connects signals to their respective slots. """ - signals.node.deleted.connect(self.on_node_deleted) - signals.database.deleted.connect(self.on_database_deleted) - signals.meta.databases_changed.connect(self.syncLater) - signals.parameter.recalculated.connect(self.syncLater) - signals.node.changed.connect(self.syncLater) + app.signals.node.deleted.connect(self.on_node_deleted) + app.signals.database.deleted.connect(self.on_database_deleted) + app.signals.meta.databases_changed.connect(self.syncLater) + app.signals.parameter.recalculated.connect(self.syncLater) + app.signals.node.changed.connect(self.syncLater) def on_node_deleted(self, node): """ diff --git a/activity_browser/layouts/pages/activity_details/activity_header.py b/activity_browser/app/pages/activity_details/activity_header.py similarity index 98% rename from activity_browser/layouts/pages/activity_details/activity_header.py rename to activity_browser/app/pages/activity_details/activity_header.py index 124c81055..40ace85ff 100644 --- a/activity_browser/layouts/pages/activity_details/activity_header.py +++ b/activity_browser/app/pages/activity_details/activity_header.py @@ -3,7 +3,7 @@ import bw2data as bd import bw_functional as bf -from activity_browser import actions, bwutils, application +from activity_browser import app, actions, bwutils from activity_browser.ui import widgets @@ -290,7 +290,7 @@ def __init__(self, parent: ActivityHeader): height = warning_label.minimumSizeHint().height() warning_icon = QtWidgets.QLabel(self) - qicon = application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) + qicon = app.application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) pixmap = qicon.pixmap(height, height) warning_icon.setPixmap(pixmap) diff --git a/activity_browser/layouts/pages/activity_details/consumers_tab.py b/activity_browser/app/pages/activity_details/consumers_tab.py similarity index 100% rename from activity_browser/layouts/pages/activity_details/consumers_tab.py rename to activity_browser/app/pages/activity_details/consumers_tab.py diff --git a/activity_browser/layouts/pages/activity_details/data_tab.py b/activity_browser/app/pages/activity_details/data_tab.py similarity index 100% rename from activity_browser/layouts/pages/activity_details/data_tab.py rename to activity_browser/app/pages/activity_details/data_tab.py diff --git a/activity_browser/layouts/pages/activity_details/description_tab.py b/activity_browser/app/pages/activity_details/description_tab.py similarity index 100% rename from activity_browser/layouts/pages/activity_details/description_tab.py rename to activity_browser/app/pages/activity_details/description_tab.py diff --git a/activity_browser/layouts/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py similarity index 100% rename from activity_browser/layouts/pages/activity_details/exchanges_tab.py rename to activity_browser/app/pages/activity_details/exchanges_tab.py diff --git a/activity_browser/layouts/pages/activity_details/graph_tab.py b/activity_browser/app/pages/activity_details/graph_tab.py similarity index 100% rename from activity_browser/layouts/pages/activity_details/graph_tab.py rename to activity_browser/app/pages/activity_details/graph_tab.py diff --git a/activity_browser/layouts/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py similarity index 98% rename from activity_browser/layouts/pages/activity_details/parameters_tab.py rename to activity_browser/app/pages/activity_details/parameters_tab.py index 2a1e14bab..4a643055d 100644 --- a/activity_browser/layouts/pages/activity_details/parameters_tab.py +++ b/activity_browser/app/pages/activity_details/parameters_tab.py @@ -3,7 +3,7 @@ import pandas as pd import bw2data as bd -from activity_browser import signals, actions +from activity_browser import app, actions from activity_browser.ui import widgets, icons, delegates from activity_browser.bwutils import refresh_node, refresh_parameter, parameters_in_scope, Parameter, database_is_locked from activity_browser.bwutils import node_group @@ -53,9 +53,9 @@ def connect_signals(self): """ Connects signals to their respective slots. """ - signals.parameter.changed.connect(self.sync) - signals.parameter.recalculated.connect(self.sync) - signals.parameter.deleted.connect(self.sync) + app.signals.parameter.changed.connect(self.sync) + app.signals.parameter.recalculated.connect(self.sync) + app.signals.parameter.deleted.connect(self.sync) def sync(self): """ diff --git a/activity_browser/layouts/pages/calculation_setup/__init__.py b/activity_browser/app/pages/calculation_setup/__init__.py similarity index 100% rename from activity_browser/layouts/pages/calculation_setup/__init__.py rename to activity_browser/app/pages/calculation_setup/__init__.py diff --git a/activity_browser/layouts/pages/calculation_setup/calculation_setup.py b/activity_browser/app/pages/calculation_setup/calculation_setup.py similarity index 94% rename from activity_browser/layouts/pages/calculation_setup/calculation_setup.py rename to activity_browser/app/pages/calculation_setup/calculation_setup.py index 091c6eaf4..5fb95c80e 100644 --- a/activity_browser/layouts/pages/calculation_setup/calculation_setup.py +++ b/activity_browser/app/pages/calculation_setup/calculation_setup.py @@ -1,6 +1,6 @@ from qtpy import QtWidgets -from activity_browser import signals, actions +from activity_browser import app, actions from activity_browser.ui import widgets, icons from .scenario_section import ScenarioSection @@ -63,8 +63,8 @@ def build_layout(self): self.setLayout(layout) def connect_signals(self): - signals.project.changed.connect(self.sync) - signals.meta.calculation_setups_changed.connect(self.sync) + app.signals.project.changed.connect(self.sync) + app.signals.meta.calculation_setups_changed.connect(self.sync) self.type_dropdown.currentTextChanged.connect(self.type_switch) self.run_button.released.connect(self.run_calculation) diff --git a/activity_browser/layouts/pages/calculation_setup/functional_unit_section.py b/activity_browser/app/pages/calculation_setup/functional_unit_section.py similarity index 100% rename from activity_browser/layouts/pages/calculation_setup/functional_unit_section.py rename to activity_browser/app/pages/calculation_setup/functional_unit_section.py diff --git a/activity_browser/layouts/pages/calculation_setup/impact_category_section.py b/activity_browser/app/pages/calculation_setup/impact_category_section.py similarity index 100% rename from activity_browser/layouts/pages/calculation_setup/impact_category_section.py rename to activity_browser/app/pages/calculation_setup/impact_category_section.py diff --git a/activity_browser/layouts/pages/calculation_setup/scenario_section.py b/activity_browser/app/pages/calculation_setup/scenario_section.py similarity index 98% rename from activity_browser/layouts/pages/calculation_setup/scenario_section.py rename to activity_browser/app/pages/calculation_setup/scenario_section.py index 2fa3167fb..7260aff44 100644 --- a/activity_browser/layouts/pages/calculation_setup/scenario_section.py +++ b/activity_browser/app/pages/calculation_setup/scenario_section.py @@ -8,7 +8,7 @@ import bw2data as bd from activity_browser.bwutils import superstructure as ss -from activity_browser import signals +from activity_browser import app from activity_browser.ui import icons, widgets from activity_browser.bwutils import errors @@ -88,9 +88,9 @@ def __init__(self, parent=None): self.connect_signals() def connect_signals(self) -> None: - signals.project.changed.connect(self.clear_tables) - signals.project.changed.connect(self.can_add_table) - signals.parameter_superstructure_built.connect(self.handle_superstructure_signal) + app.signals.project.changed.connect(self.clear_tables) + app.signals.project.changed.connect(self.can_add_table) + app.signals.parameter_superstructure_built.connect(self.handle_superstructure_signal) self.table_btn.clicked.connect(self.add_table) self.table_btn.clicked.connect(self.can_add_table) diff --git a/activity_browser/layouts/pages/impact_category_details/__init__.py b/activity_browser/app/pages/impact_category_details/__init__.py similarity index 100% rename from activity_browser/layouts/pages/impact_category_details/__init__.py rename to activity_browser/app/pages/impact_category_details/__init__.py diff --git a/activity_browser/layouts/pages/impact_category_details/impact_category_details.py b/activity_browser/app/pages/impact_category_details/impact_category_details.py similarity index 96% rename from activity_browser/layouts/pages/impact_category_details/impact_category_details.py rename to activity_browser/app/pages/impact_category_details/impact_category_details.py index dad194d37..6b15b862b 100644 --- a/activity_browser/layouts/pages/impact_category_details/impact_category_details.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_details.py @@ -4,7 +4,7 @@ import bw2data as bd import pandas as pd -from activity_browser import actions, signals +from activity_browser import actions, app from activity_browser.ui import widgets, icons, delegates from activity_browser.bwutils import AB_metadata, is_node_biosphere @@ -36,9 +36,9 @@ def __init__(self, name: tuple, parent=None): self.view.resizeColumnToContents(1) def connect_signals(self): - signals.method.renamed.connect(self.on_method_renamed) - signals.method.deleted.connect(self.on_method_deleted) - signals.meta.methods_changed.connect(self.sync) + app.signals.method.renamed.connect(self.on_method_renamed) + app.signals.method.deleted.connect(self.on_method_deleted) + app.signals.meta.methods_changed.connect(self.sync) def on_method_renamed(self, old_name, new_name): if self.name == old_name: diff --git a/activity_browser/layouts/pages/impact_category_details/impact_category_header.py b/activity_browser/app/pages/impact_category_details/impact_category_header.py similarity index 100% rename from activity_browser/layouts/pages/impact_category_details/impact_category_header.py rename to activity_browser/app/pages/impact_category_details/impact_category_header.py diff --git a/activity_browser/layouts/pages/lca_results/LCA_results.py b/activity_browser/app/pages/lca_results/LCA_results.py similarity index 99% rename from activity_browser/layouts/pages/lca_results/LCA_results.py rename to activity_browser/app/pages/lca_results/LCA_results.py index d00dea420..3272a2fde 100644 --- a/activity_browser/layouts/pages/lca_results/LCA_results.py +++ b/activity_browser/app/pages/lca_results/LCA_results.py @@ -11,7 +11,7 @@ from stats_arrays.errors import InvalidParamsError -from activity_browser import signals, bwutils, settings +from activity_browser import app, bwutils, settings from activity_browser.mod.bw2analyzer import ABContributionAnalysis from activity_browser.ui import icons, web, widgets @@ -1884,7 +1884,7 @@ def calculate_mc_lca(self): QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) try: self.parent.mc.calculate(iterations=iterations, seed=seed, **includes) - signals.monte_carlo_finished.emit() + app.signals.monte_carlo_finished.emit() self.update_mc() except ( InvalidParamsError @@ -2053,7 +2053,7 @@ def __init__(self, parent=None): def connect_signals(self): self.button_run.clicked.connect(self.calculate_gsa) - signals.monte_carlo_finished.connect(self.monte_carlo_finished) + app.signals.monte_carlo_finished.connect(self.monte_carlo_finished) def add_GSA_ui_elements(self): # H-LAYOUT SETTINGS ROW 1 @@ -2250,7 +2250,7 @@ def run(self): self.mc.calculate(iterations=self.iterations) # res = bw.GraphTraversal().calculate(self.demand, self.method, self.cutoff, self.max_calc) logger.info("in thread {}".format(QtCore.QThread.currentThread())) - signals.monte_carlo_ready.emit(self.mc.cs_name) + app.signals.monte_carlo_ready.emit(self.mc.cs_name) worker_thread = MonteCarloWorkerThread() diff --git a/activity_browser/layouts/pages/lca_results/__init__.py b/activity_browser/app/pages/lca_results/__init__.py similarity index 100% rename from activity_browser/layouts/pages/lca_results/__init__.py rename to activity_browser/app/pages/lca_results/__init__.py diff --git a/activity_browser/layouts/pages/lca_results/dialogs.py b/activity_browser/app/pages/lca_results/dialogs.py similarity index 100% rename from activity_browser/layouts/pages/lca_results/dialogs.py rename to activity_browser/app/pages/lca_results/dialogs.py diff --git a/activity_browser/layouts/pages/lca_results/plots.py b/activity_browser/app/pages/lca_results/plots.py similarity index 100% rename from activity_browser/layouts/pages/lca_results/plots.py rename to activity_browser/app/pages/lca_results/plots.py diff --git a/activity_browser/layouts/pages/lca_results/style.py b/activity_browser/app/pages/lca_results/style.py similarity index 100% rename from activity_browser/layouts/pages/lca_results/style.py rename to activity_browser/app/pages/lca_results/style.py diff --git a/activity_browser/layouts/pages/lca_results/tables.py b/activity_browser/app/pages/lca_results/tables.py similarity index 100% rename from activity_browser/layouts/pages/lca_results/tables.py rename to activity_browser/app/pages/lca_results/tables.py diff --git a/activity_browser/layouts/pages/metadatastore.py b/activity_browser/app/pages/metadatastore.py similarity index 100% rename from activity_browser/layouts/pages/metadatastore.py rename to activity_browser/app/pages/metadatastore.py diff --git a/activity_browser/layouts/pages/parameters/__init__.py b/activity_browser/app/pages/parameters/__init__.py similarity index 100% rename from activity_browser/layouts/pages/parameters/__init__.py rename to activity_browser/app/pages/parameters/__init__.py diff --git a/activity_browser/layouts/pages/parameters/base.py b/activity_browser/app/pages/parameters/base.py similarity index 100% rename from activity_browser/layouts/pages/parameters/base.py rename to activity_browser/app/pages/parameters/base.py diff --git a/activity_browser/layouts/pages/parameters/parameter_models.py b/activity_browser/app/pages/parameters/parameter_models.py similarity index 100% rename from activity_browser/layouts/pages/parameters/parameter_models.py rename to activity_browser/app/pages/parameters/parameter_models.py diff --git a/activity_browser/layouts/pages/parameters/parameter_views.py b/activity_browser/app/pages/parameters/parameter_views.py similarity index 100% rename from activity_browser/layouts/pages/parameters/parameter_views.py rename to activity_browser/app/pages/parameters/parameter_views.py diff --git a/activity_browser/layouts/pages/parameters/parameters.py b/activity_browser/app/pages/parameters/parameters.py similarity index 100% rename from activity_browser/layouts/pages/parameters/parameters.py rename to activity_browser/app/pages/parameters/parameters.py diff --git a/activity_browser/layouts/pages/parameters/parameters_new.py b/activity_browser/app/pages/parameters/parameters_new.py similarity index 98% rename from activity_browser/layouts/pages/parameters/parameters_new.py rename to activity_browser/app/pages/parameters/parameters_new.py index ef4780fc0..f2cc3ae3c 100644 --- a/activity_browser/layouts/pages/parameters/parameters_new.py +++ b/activity_browser/app/pages/parameters/parameters_new.py @@ -5,7 +5,7 @@ from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, ParameterizedExchange from bw2data.backends import ExchangeDataset -from activity_browser import signals, actions +from activity_browser import app, actions from activity_browser.ui import widgets, icons, delegates from activity_browser.bwutils import refresh_parameter, refresh_node, Parameter, database_is_locked, AB_metadata @@ -88,11 +88,11 @@ def connect_signals(self): Connects signals to their respective slots. """ AB_metadata.synced.connect(self.sync) - signals.parameter.changed.connect(self.sync) - signals.parameter.recalculated.connect(self.sync) - signals.parameter.deleted.connect(self.sync) - signals.project.changed.connect(self.sync) - signals.meta.databases_changed.connect(self.sync) + app.signals.parameter.changed.connect(self.sync) + app.signals.parameter.recalculated.connect(self.sync) + app.signals.parameter.deleted.connect(self.sync) + app.signals.project.changed.connect(self.sync) + app.signals.meta.databases_changed.connect(self.sync) def sync(self): """ diff --git a/activity_browser/layouts/pages/welcome.py b/activity_browser/app/pages/welcome.py similarity index 95% rename from activity_browser/layouts/pages/welcome.py rename to activity_browser/app/pages/welcome.py index 1d099aaa4..27e305106 100644 --- a/activity_browser/layouts/pages/welcome.py +++ b/activity_browser/app/pages/welcome.py @@ -2,7 +2,7 @@ from qtpy import QtWebEngineWidgets, QtWidgets, QtCore, QtGui, QtWebChannel -from activity_browser import actions, signals +from activity_browser import actions, app from activity_browser.static import startscreen from activity_browser.bwutils import projects_by_last_opened @@ -31,7 +31,7 @@ def __init__(self, parent=None): self.setLayout(self.vl) self.bridge.ready.connect(self.update_welcome) - signals.project.changed.connect(lambda: self.page.load(self.url)) + app.signals.project.changed.connect(lambda: self.page.load(self.url)) def update_welcome(self): projects = projects_by_last_opened() diff --git a/activity_browser/layouts/panes/__init__.py b/activity_browser/app/panes/__init__.py similarity index 100% rename from activity_browser/layouts/panes/__init__.py rename to activity_browser/app/panes/__init__.py diff --git a/activity_browser/layouts/panes/calculation_setups.py b/activity_browser/app/panes/calculation_setups.py similarity index 97% rename from activity_browser/layouts/panes/calculation_setups.py rename to activity_browser/app/panes/calculation_setups.py index 821219415..c0ff7198b 100644 --- a/activity_browser/layouts/panes/calculation_setups.py +++ b/activity_browser/app/panes/calculation_setups.py @@ -3,7 +3,7 @@ import bw2data as bd import pandas as pd -from activity_browser import signals, actions +from activity_browser import app, actions from activity_browser.ui import widgets, delegates, core @@ -36,8 +36,8 @@ def connect_signals(self): """ Connects the signals to the appropriate slots. """ - signals.meta.calculation_setups_changed.connect(self.sync) - signals.project.changed.connect(self.sync) + app.signals.meta.calculation_setups_changed.connect(self.sync) + app.signals.project.changed.connect(self.sync) def build_layout(self): """ diff --git a/activity_browser/layouts/panes/database_explorer.py b/activity_browser/app/panes/database_explorer.py similarity index 98% rename from activity_browser/layouts/panes/database_explorer.py rename to activity_browser/app/panes/database_explorer.py index e5777818f..358af7308 100644 --- a/activity_browser/layouts/panes/database_explorer.py +++ b/activity_browser/app/panes/database_explorer.py @@ -5,10 +5,9 @@ import bw2data as bd -from activity_browser import signals from activity_browser.bwutils import AB_metadata from activity_browser.ui import widgets -from activity_browser.ui.core import application +from activity_browser.app import application, signals diff --git a/activity_browser/layouts/panes/database_products.py b/activity_browser/app/panes/database_products.py similarity index 98% rename from activity_browser/layouts/panes/database_products.py rename to activity_browser/app/panes/database_products.py index 1aaf892b0..f2f3bbd4e 100644 --- a/activity_browser/layouts/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -7,7 +7,7 @@ import bw2data as bd -from activity_browser import actions, ui, signals, application +from activity_browser import actions, ui, app from activity_browser.settings import project_settings from activity_browser.ui import core, widgets, delegates, icons from activity_browser.bwutils import AB_metadata, database_is_locked, database_is_legacy @@ -103,7 +103,7 @@ def build_layout(self): def connect_signals(self): AB_metadata.synced.connect(self.on_metadata_changed) - signals.database.deleted.connect(self.on_database_deleted) + app.signals.database.deleted.connect(self.on_database_deleted) self.table_view.filtered.connect(self.search_error) self.search.textChangedDebounce.connect(self.table_view.setAllFilter) @@ -195,7 +195,7 @@ def search_error(self, reset=False): reset (bool, optional): Whether to reset the search bar color. Defaults to False. """ if reset: - self.search.setPalette(application.palette()) + self.search.setPalette(app.application.palette()) return palette = self.search.palette() diff --git a/activity_browser/layouts/panes/databases.py b/activity_browser/app/panes/databases.py similarity index 96% rename from activity_browser/layouts/panes/databases.py rename to activity_browser/app/panes/databases.py index d474348d1..515c03eb5 100644 --- a/activity_browser/layouts/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -6,9 +6,9 @@ import bw2data as bd import pandas as pd -from activity_browser import signals, actions, bwutils +from activity_browser import app, actions, bwutils from activity_browser.ui import widgets, icons, delegates, core -from activity_browser.layouts.menu_bar import ImportDatabaseMenu +from activity_browser.app.menu_bar import ImportDatabaseMenu class DatabasesPane(widgets.ABAbstractPane): @@ -44,10 +44,10 @@ def connect_signals(self): """ Connects the signals to the appropriate slots. """ - signals.meta.databases_changed.connect(self.sync) - signals.project.changed.connect(self.sync) - signals.database.deleted.connect(self.sync) - signals.database_read_only_changed.connect(self.sync) + app.signals.meta.databases_changed.connect(self.sync) + app.signals.project.changed.connect(self.sync) + app.signals.database.deleted.connect(self.sync) + app.signals.database_read_only_changed.connect(self.sync) def build_layout(self): """ diff --git a/activity_browser/layouts/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py similarity index 96% rename from activity_browser/layouts/panes/impact_categories.py rename to activity_browser/app/panes/impact_categories.py index 04fcfcd54..9d12a17da 100644 --- a/activity_browser/layouts/panes/impact_categories.py +++ b/activity_browser/app/panes/impact_categories.py @@ -4,7 +4,7 @@ import bw2data as bd import pandas as pd -from activity_browser import signals, actions +from activity_browser import app, actions from activity_browser.ui import widgets, core, delegates @@ -42,9 +42,9 @@ def build_layout(self): self.setLayout(layout) def connect_signals(self): - signals.meta.methods_changed.connect(self.sync) - signals.project.changed.connect(self.sync) - signals.database_read_only_changed.connect(self.sync) + app.signals.meta.methods_changed.connect(self.sync) + app.signals.project.changed.connect(self.sync) + app.signals.database_read_only_changed.connect(self.sync) def load(self): df = self.build_df() diff --git a/activity_browser/layouts/panes/project_manager.py b/activity_browser/app/panes/project_manager.py similarity index 95% rename from activity_browser/layouts/panes/project_manager.py rename to activity_browser/app/panes/project_manager.py index 7b224ce82..0f6afe894 100644 --- a/activity_browser/layouts/panes/project_manager.py +++ b/activity_browser/app/panes/project_manager.py @@ -6,7 +6,7 @@ import bw2data as bd from bw2io import remote -from activity_browser import actions, ui, signals, utils +from activity_browser import actions, ui, app, utils from activity_browser.settings import ab_settings from activity_browser.ui import widgets @@ -47,8 +47,8 @@ def __init__(self, parent=None): self.setLayout(layout) # connect signals - signals.project.changed.connect(self.sync) - signals.project.deleted.connect(self.sync) + app.signals.project.changed.connect(self.sync) + app.signals.project.deleted.connect(self.sync) def sync(self): self.project_model.setDataFrame(self.build_project_df()) @@ -97,7 +97,7 @@ class ProjectView(widgets.ABTreeView): class ContextMenu(widgets.ABTreeView.ContextMenu): def __init__(self, pos, view: "FunctionView"): - from activity_browser.layouts.menu_bar import ProjectNewMenu + from activity_browser.app.menu_bar import ProjectNewMenu super().__init__(pos, view) items = list({index.internalPointer() for index in view.selectedIndexes()}) diff --git a/activity_browser/signals.py b/activity_browser/app/signals.py similarity index 99% rename from activity_browser/signals.py rename to activity_browser/app/signals.py index 2f032d993..3bb1ae930 100644 --- a/activity_browser/signals.py +++ b/activity_browser/app/signals.py @@ -275,12 +275,10 @@ def patch_projects(): from bw2data.project import ProjectManager def delete_project(self, name=None, delete_dir=False): + from activity_browser.app import signals original_delete(self, name, delete_dir) signals.project.deleted.emit(name) original_delete = ProjectManager.delete_project setattr(ProjectManager, "delete_project", delete_project) - - -signals = ABSignals() diff --git a/activity_browser/bwutils/__init__.py b/activity_browser/bwutils/__init__.py index f15c26be6..0309bbe03 100644 --- a/activity_browser/bwutils/__init__.py +++ b/activity_browser/bwutils/__init__.py @@ -9,7 +9,7 @@ from .commontasks import (refresh_node, refresh_node_or_none, refresh_parameter, refresh_edge, refresh_edge_or_none, parameters_in_scope, exchanges_to_sdf, database_is_locked, database_is_legacy, projects_by_last_opened, node_group, is_node_product, is_node_biosphere, is_node_process) -from .metadata import AB_metadata +from .metadata import MetaDataStore from .montecarlo import MonteCarloLCA from .multilca import MLCA, Contributions from .pedigree import PedigreeMatrix diff --git a/activity_browser/bwutils/metadata/__init__.py b/activity_browser/bwutils/metadata/__init__.py index f4aa82ad7..6138067fb 100644 --- a/activity_browser/bwutils/metadata/__init__.py +++ b/activity_browser/bwutils/metadata/__init__.py @@ -1 +1 @@ -from .metadata import AB_metadata \ No newline at end of file +from .metadata import MetaDataStore \ No newline at end of file diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 0261cb929..00ff3b04d 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -1,7 +1,6 @@ -import subprocess import sqlite3 -import sys import pickle +from multiprocessing import Pool from loguru import logger from typing import Literal @@ -11,7 +10,6 @@ from qtpy import QtCore -from activity_browser import signals, application from activity_browser.ui.core import threading from .metadata import MetaDataStore @@ -31,7 +29,8 @@ def __init__(self, mds: MetaDataStore): self.connect_signals() def connect_signals(self): - signals.project.changed.connect(self.on_project_changed) + from activity_browser import app + app.signals.project.changed.connect(self.on_project_changed) def on_project_changed(self): self.load_project() @@ -137,28 +136,30 @@ class SecondaryLoadThread(threading.ABThread): done: QtCore.SignalInstance = QtCore.Signal(pd.DataFrame, str) def run_safely(self, databases: list[str], sqlite_db: str): - processes = [self.open_load_process(db, sqlite_db) for db in databases] + with Pool() as pool: + args = [(sqlite_db, db, secondary) for db in databases] + results = pool.starmap(load, args) full_df = pd.DataFrame() - for proc in processes: - stdout_data, stderr_data = proc.communicate() - if proc.returncode != 0: - logger.error(f"Error loading metadata: {stderr_data.decode()}") + for df in results: + if df is None or df.empty: continue - df = pickle.loads(stdout_data) - if df.empty: - continue - full_df = pd.concat([full_df, df]) self.done.emit(full_df, sqlite_db) - def open_load_process(self, database_name: str, sqlite_db: str) -> subprocess.Popen: - import activity_browser.bwutils.metadata._sub_loader as sl - return subprocess.Popen( - [sys.executable, sl.__file__, str(sqlite_db), database_name] + secondary, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) +def load(fp: str, database_name: str, fields: list[str]): + con = sqlite3.connect(fp) + sql = f"SELECT data FROM activitydataset WHERE database = '{database_name}'" + raw_df = pd.read_sql(sql, con) + con.close() + + df = pd.DataFrame([pickle.loads(x) for x in raw_df["data"]]) + if df.empty: + return df + df["key"] = list(zip(df["database"], df["code"])) + df.index = pd.MultiIndex.from_tuples(df["key"], names=["database", "code"]) + df = df.reindex(columns=fields)[fields] + return df \ No newline at end of file diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 6c691f4d1..19c1173ee 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -4,19 +4,16 @@ import pandas as pd -from qtpy.QtCore import Qt, QObject, Signal, SignalInstance, QTimer +from qtpy.QtCore import QObject, Signal, SignalInstance, QTimer from .fields import all, all_types - - - class MetaDataStore(QObject): synced: SignalInstance = Signal(set, set, set) # added, updated, deleted def __init__(self, parent=None): - from activity_browser import application + from activity_browser import app from .loader import MDSLoader from .updater import MDSUpdater @@ -32,7 +29,7 @@ def __init__(self, parent=None): self.updater = MDSUpdater(self) self.flusher: QTimer | None = None - self.moveToThread(application.thread()) + self.moveToThread(app.application.thread()) @property def dataframe(self) -> pd.DataFrame: @@ -112,5 +109,3 @@ def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFr if db_name not in self.databases: return pd.DataFrame(columns=all) return self.dataframe.loc[[db_name], columns or all] - -AB_metadata = MetaDataStore() diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index 4070f3000..6960bbeae 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -2,12 +2,9 @@ import pandas as pd import numpy as np -import timeit from qtpy import QtCore -from activity_browser import signals, application - from .metadata import MetaDataStore from .fields import primary, secondary, all_types @@ -22,6 +19,7 @@ def __init__(self, mds: MetaDataStore): self.connect_signals() def connect_signals(self): + from activity_browser.app import signals signals.node.changed.connect(self.on_node_changed) signals.node.deleted.connect(self.on_node_deleted) diff --git a/activity_browser/layouts/__init__.py b/activity_browser/layouts/__init__.py deleted file mode 100644 index 416f9e4fa..000000000 --- a/activity_browser/layouts/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- -__all__ = ["panes", "pages", "MainWindow"] - -from .main_window import MainWindow diff --git a/activity_browser/settings.py b/activity_browser/settings.py index b74453a30..5655c1d3b 100644 --- a/activity_browser/settings.py +++ b/activity_browser/settings.py @@ -11,7 +11,7 @@ import platformdirs from qtpy.QtWidgets import QMessageBox -from .signals import signals +from .app import signals DEFAULT_BW_DATA_DIR = bd.projects._base_data_dir diff --git a/activity_browser/ui/core/threading.py b/activity_browser/ui/core/threading.py index 7fca4582a..d2ce05b23 100644 --- a/activity_browser/ui/core/threading.py +++ b/activity_browser/ui/core/threading.py @@ -15,8 +15,8 @@ class ABThread(QThread): def __init__(self, parent=None): super().__init__(parent) - from activity_browser import application - self.exception.connect(application.main_window.dialog_on_exception) + from activity_browser import app + self.exception.connect(app.main_window.dialog_on_exception) def start(self, *args, priority=QThread.NormalPriority, **kwargs): """ diff --git a/activity_browser/ui/delegates/formula.py b/activity_browser/ui/delegates/formula.py index f825d0c81..3600616ff 100644 --- a/activity_browser/ui/delegates/formula.py +++ b/activity_browser/ui/delegates/formula.py @@ -5,7 +5,7 @@ from qtpy import QtCore, QtGui, QtWidgets from qtpy.QtCore import Signal, Slot -from activity_browser import actions, signals +from activity_browser import actions, app class CalculatorButtons(QtWidgets.QWidget): @@ -112,7 +112,7 @@ def __init__(self, parent=None, flags=QtCore.Qt.Window): self.buttons.accepted.connect(self.accept) self.buttons.rejected.connect(self.reject) - signals.added_parameter.connect(self.append_parameter) + app.signals.added_parameter.connect(self.append_parameter) self.show() def insert_parameters(self, items) -> None: diff --git a/activity_browser/ui/delegates/uncertainty.py b/activity_browser/ui/delegates/uncertainty.py index 7a799c29c..db97997ab 100644 --- a/activity_browser/ui/delegates/uncertainty.py +++ b/activity_browser/ui/delegates/uncertainty.py @@ -4,7 +4,7 @@ from activity_browser import actions -from activity_browser.signals import signals +from activity_browser.app import signals class UncertaintyDelegate(QtWidgets.QStyledItemDelegate): diff --git a/activity_browser/ui/dialogs/progress_dialog.py b/activity_browser/ui/dialogs/progress_dialog.py index 97e0faf11..088193678 100644 --- a/activity_browser/ui/dialogs/progress_dialog.py +++ b/activity_browser/ui/dialogs/progress_dialog.py @@ -1,6 +1,6 @@ from qtpy.QtWidgets import QProgressDialog -from activity_browser import application +from activity_browser.app import application from activity_browser.mod.tqdm import qt_tqdm from activity_browser.mod.pyprind import qt_pyprind diff --git a/activity_browser/ui/dialogs/uncertainty.py b/activity_browser/ui/dialogs/uncertainty.py index 97c476647..12e88e927 100644 --- a/activity_browser/ui/dialogs/uncertainty.py +++ b/activity_browser/ui/dialogs/uncertainty.py @@ -8,7 +8,7 @@ from stats_arrays import uncertainty_choices as uncertainty from stats_arrays.distributions import * -from activity_browser import actions, application +from activity_browser import actions, app from activity_browser.ui.widgets.plot import ABPlot from ...bwutils import PedigreeMatrix, get_uncertainty_interface @@ -165,7 +165,7 @@ def amount_mean_test(self) -> None: actions.ParameterModify.run(self.obj.data, "amount", mean) except Exception as e: QtWidgets.QMessageBox.warning( - application.main_window, + app.main_window, "Could not save changes", str(e), QtWidgets.QMessageBox.Ok, diff --git a/activity_browser/ui/icons.py b/activity_browser/ui/icons.py index 880c44f70..d656fc779 100644 --- a/activity_browser/ui/icons.py +++ b/activity_browser/ui/icons.py @@ -13,6 +13,7 @@ def create_path(folder: str, filename: str) -> str: def empty_icon(size: QSize = QSize(32, 32)) -> QIcon: + print("This it?") pixmap = QPixmap(size) pixmap.fill(Qt.transparent) # Make the pixmap transparent return QIcon(pixmap) diff --git a/activity_browser/ui/web/base.py b/activity_browser/ui/web/base.py index 16d25e574..14b38e4cc 100644 --- a/activity_browser/ui/web/base.py +++ b/activity_browser/ui/web/base.py @@ -8,7 +8,7 @@ from qtpy import QtWebChannel, QtWebEngineWidgets, QtWidgets from qtpy.QtCore import QObject, Qt, QUrl, Signal, Slot -from activity_browser import signals +from activity_browser import app from activity_browser.settings import ab_settings from activity_browser.mod import bw2data as bd @@ -78,17 +78,17 @@ def toggle_help(self) -> None: def go_forward(self) -> None: if self.graph.forward(): - signals.new_statusbar_message.emit("Going forward.") + app.signals.new_statusbar_message.emit("Going forward.") self.send_json() else: - signals.new_statusbar_message.emit("No data to go forward to.") + app.signals.new_statusbar_message.emit("No data to go forward to.") def go_back(self) -> None: if self.graph.back(): - signals.new_statusbar_message.emit("Going back.") + app.signals.new_statusbar_message.emit("Going back.") self.send_json() else: - signals.new_statusbar_message.emit("No data to go back to.") + app.signals.new_statusbar_message.emit("No data to go back to.") def send_json(self) -> None: if self.graph.json_data is None: diff --git a/activity_browser/ui/web/navigator.py b/activity_browser/ui/web/navigator.py index 54942d0f0..78308196c 100644 --- a/activity_browser/ui/web/navigator.py +++ b/activity_browser/ui/web/navigator.py @@ -9,7 +9,7 @@ from qtpy import QtWidgets from qtpy.QtCore import Slot -from activity_browser import signals +from activity_browser import app from bw2data import Database, get_activity, databases, Edge from bw2data.backends import ExchangeDataset, ActivityDataset @@ -198,7 +198,7 @@ def new_graph(self, key: tuple) -> None: @Slot(name="reload_graph") def reload_graph(self) -> None: - signals.new_statusbar_message.emit("Reloading graph") + app.signals.new_statusbar_message.emit("Reloading graph") self.graph.update(delete_unstacked=False) @Slot(object, name="update_graph") diff --git a/activity_browser/ui/web/sankey_navigator.py b/activity_browser/ui/web/sankey_navigator.py index 13e84db80..dab378aeb 100644 --- a/activity_browser/ui/web/sankey_navigator.py +++ b/activity_browser/ui/web/sankey_navigator.py @@ -15,7 +15,7 @@ from qtpy.QtCore import Slot from qtpy.QtWidgets import QComboBox -from activity_browser import signals +from activity_browser import app from activity_browser.mod import bw2data as bd from bw2data.backends import ActivityDataset @@ -87,7 +87,7 @@ def load_finished_handler(self) -> None: def connect_signals(self): super().connect_signals() self.button_calculate.clicked.connect(self.new_sankey) - signals.database_selected.connect(self.set_database) + app.signals.database_selected.connect(self.set_database) # checkboxes self.func_unit_cb.currentIndexChanged.connect(self.new_sankey) self.method_cb.currentIndexChanged.connect(self.new_sankey) diff --git a/activity_browser/ui/web/tree_navigator.py b/activity_browser/ui/web/tree_navigator.py index 1c48c37d2..83fb4778e 100644 --- a/activity_browser/ui/web/tree_navigator.py +++ b/activity_browser/ui/web/tree_navigator.py @@ -20,8 +20,7 @@ GroupedNodes as GraphGroupedNodes, ) -from activity_browser import signals -from activity_browser.mod import bw2data as bd +from activity_browser import app from bw2data.backends import ActivityDataset from activity_browser.utils import get_base_path from .base import BaseGraph, BaseNavigatorWidget @@ -88,7 +87,7 @@ def load_finished_handler(self) -> None: def connect_signals(self): super().connect_signals() self.button_calculate.clicked.connect(self.new_tree) - signals.database_selected.connect(self.set_database) + app.signals.database_selected.connect(self.set_database) # checkboxes self.func_unit_cb.currentIndexChanged.connect(self.new_tree) self.method_cb.currentIndexChanged.connect(self.new_tree) diff --git a/activity_browser/ui/widgets/central.py b/activity_browser/ui/widgets/central.py index f9cf0fa78..4215751d7 100644 --- a/activity_browser/ui/widgets/central.py +++ b/activity_browser/ui/widgets/central.py @@ -2,7 +2,7 @@ from qtpy import QtWidgets -from activity_browser import signals +from activity_browser.app import signals diff --git a/tests/conftest.py b/tests/conftest.py index 0da7af01e..f712017e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ from activity_browser import application from activity_browser.ui.widgets import CentralTabWidget -from activity_browser.layouts import pages, MainWindow +from activity_browser.app import pages, MainWindow @pytest.fixture From ec6506d8969e68a93d40753716b3eef19b0056d6 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 6 Nov 2025 12:42:03 +0100 Subject: [PATCH 091/267] Refactor metadata handling across the application - Replaced instances of `bwutils.AB_metadata` with `app.metadata` to unify metadata access. - Updated various modules including `activity_header.py`, `consumers_tab.py`, `exchanges_tab.py`, and others to ensure consistent metadata retrieval. - Introduced a new `MetaDataSignals` class to handle metadata synchronization signals. - Modified the `MetaDataStore` and `MDSLoader` classes to streamline metadata loading and updating processes. - Adjusted signal connections in multiple components to utilize the new metadata signal structure. - Cleaned up imports and removed redundant code related to the old metadata handling. --- activity_browser/__main__.py | 21 ++--- .../actions/database/database_delete.py | 3 +- .../actions/metadatastore_open.py | 2 +- .../actions/project/project_migrate25.py | 3 +- activity_browser/app/__init__.py | 5 +- .../pages/activity_details/activity_header.py | 2 +- .../pages/activity_details/consumers_tab.py | 6 +- .../pages/activity_details/exchanges_tab.py | 8 +- .../functional_unit_section.py | 8 +- .../impact_category_details.py | 4 +- .../app/pages/lca_results/LCA_results.py | 2 +- activity_browser/app/pages/metadatastore.py | 8 +- .../app/pages/parameters/parameters_new.py | 8 +- .../app/panes/database_explorer.py | 7 +- .../app/panes/database_products.py | 8 +- activity_browser/app/signals.py | 30 ++++++- activity_browser/bwutils/__init__.py | 2 +- activity_browser/bwutils/commontasks.py | 10 ++- activity_browser/bwutils/exporters.py | 3 +- activity_browser/bwutils/importers.py | 3 +- activity_browser/bwutils/metadata/loader.py | 89 +++++++++++-------- activity_browser/bwutils/metadata/metadata.py | 38 +++----- activity_browser/bwutils/metadata/updater.py | 48 +++++++--- activity_browser/bwutils/montecarlo.py | 8 +- activity_browser/bwutils/multilca.py | 25 +++--- .../bwutils/sensitivity_analysis.py | 9 +- .../bwutils/superstructure/dataframe.py | 10 ++- .../bwutils/superstructure/file_dialogs.py | 7 +- .../bwutils/superstructure/mlca.py | 4 +- activity_browser/bwutils/utils.py | 1 - .../{app => ui/core}/application.py | 15 +++- activity_browser/ui/web/tree_navigator.py | 1 - activity_browser/ui/widgets/__init__.py | 2 +- activity_browser/ui/widgets/plot.py | 12 ++- 34 files changed, 232 insertions(+), 180 deletions(-) rename activity_browser/{app => ui/core}/application.py (92%) diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index 9ee7ecae5..be387ee4c 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -12,8 +12,6 @@ import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("activity.browser.1") -from activity_browser import app - from loguru import logger import platformdirs from .static.icons import main @@ -88,8 +86,7 @@ def load_modules(self): def load_layout(self): from .ui.widgets import CentralTabWidget - from .app import panes, pages, application - from activity_browser.bwutils import AB_metadata + from .app import pages, application central_widget = CentralTabWidget(application.main_window) central_widget.addTab(pages.WelcomePage(), "Welcome") @@ -122,12 +119,6 @@ def run(self): self.status.emit("Loading Brightway25") logger.debug("ABLoader: Importing brightway modules") import bw2data, bw2calc, bw2analyzer, bw2io, bw_functional, bw_processing, matrix_utils - self.status.emit("Loading Activity Browser") - logger.debug("ABLoader: Importing activity_browser") - from activity_browser import actions, app, mod, settings, ui - from activity_browser.app import panes, pages - from activity_browser.ui import core, widgets, web, wizards - class SettingsThread(QtCore.QThread): def run(self): @@ -162,13 +153,16 @@ def setup_logging(): def run_activity_browser(): + from activity_browser.ui.core.application import ABApplication + app = ABApplication() + pre_flight_checks() setup_logging() loader = ABLoader() loader.show() - app.application.set_icon() # setting this here seems to fix the icon not showing sometimes - sys.exit(app.application.exec_()) + app.set_icon() # setting this here seems to fix the icon not showing sometimes + sys.exit(app.exec_()) def run_activity_browser_no_launcher(): @@ -179,8 +173,7 @@ def run_activity_browser_no_launcher(): modules.run() from .ui.widgets import CentralTabWidget - from .app import panes, pages, application - from activity_browser.bwutils import AB_metadata + from .app import panes, pages, application, metadata from activity_browser import signals central_widget = CentralTabWidget(application.main_window) diff --git a/activity_browser/actions/database/database_delete.py b/activity_browser/actions/database/database_delete.py index 1641607d8..1bf912ab1 100644 --- a/activity_browser/actions/database/database_delete.py +++ b/activity_browser/actions/database/database_delete.py @@ -7,7 +7,6 @@ from bw2data.backends.proxies import ExchangeDataset, Exchanges from activity_browser import app, settings -from activity_browser.bwutils import AB_metadata from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -51,7 +50,7 @@ def run(db_names: List[str]): # get the total record count from all databases total_records = 0 for db_name in db_names: - n_records = AB_metadata.dataframe[AB_metadata.dataframe["database"] == db_name].shape[0] + n_records = app.metadata.dataframe[app.metadata.dataframe["database"] == db_name].shape[0] total_records += n_records # construct warning text diff --git a/activity_browser/actions/metadatastore_open.py b/activity_browser/actions/metadatastore_open.py index b2418ce96..6ee2c41be 100644 --- a/activity_browser/actions/metadatastore_open.py +++ b/activity_browser/actions/metadatastore_open.py @@ -3,7 +3,7 @@ from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -from activity_browser.app.application import global_shortcut +from activity_browser.ui.core.application import global_shortcut diff --git a/activity_browser/actions/project/project_migrate25.py b/activity_browser/actions/project/project_migrate25.py index a1e5f2902..14b61e359 100644 --- a/activity_browser/actions/project/project_migrate25.py +++ b/activity_browser/actions/project/project_migrate25.py @@ -7,7 +7,6 @@ from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils import AB_metadata from activity_browser.ui.icons import qicons from activity_browser.ui.core.threading import ABThread @@ -116,7 +115,7 @@ def pre_process_methods(cls): if isinstance(v[0], tuple) and len(v) == 2 and len(v[0]) == 2], columns=["method", "database", "code", "value"]) - df = df.merge(AB_metadata.dataframe["id"], left_on=["database", "code"], right_index=True) + df = df.merge(app.metadata.dataframe["id"], left_on=["database", "code"], right_index=True) app.signals.method.blockSignals(True) app.signals.meta.blockSignals(True) diff --git a/activity_browser/app/__init__.py b/activity_browser/app/__init__.py index 8bf6ef6dc..ad7924512 100644 --- a/activity_browser/app/__init__.py +++ b/activity_browser/app/__init__.py @@ -1,10 +1,8 @@ # -*- coding: utf-8 -*- __all__ = ["panes", "pages", "application", "signals", "metadata", "main_window"] -from activity_browser.bwutils import MetaDataStore - +from activity_browser.ui.core.application import ABApplication from .main_window import MainWindow -from .application import ABApplication from .signals import ABSignals application = ABApplication() @@ -14,4 +12,5 @@ main_window = MainWindow() application.main_window = main_window +from activity_browser.bwutils import MetaDataStore metadata = MetaDataStore() diff --git a/activity_browser/app/pages/activity_details/activity_header.py b/activity_browser/app/pages/activity_details/activity_header.py index 40ace85ff..6c73df1e8 100644 --- a/activity_browser/app/pages/activity_details/activity_header.py +++ b/activity_browser/app/pages/activity_details/activity_header.py @@ -151,7 +151,7 @@ def __init__(self, parent: ActivityHeader): super().__init__(parent.activity.get("location"), parent) self.editingFinished.connect(self.change_location) - locations = set(bwutils.AB_metadata.dataframe.get("location", ["GLO"])) + locations = set(app.metadata.dataframe.get("location", ["GLO"])) completer = QtWidgets.QCompleter(locations, self) self.setCompleter(completer) diff --git a/activity_browser/app/pages/activity_details/consumers_tab.py b/activity_browser/app/pages/activity_details/consumers_tab.py index 5e2397dea..7dc5cf721 100644 --- a/activity_browser/app/pages/activity_details/consumers_tab.py +++ b/activity_browser/app/pages/activity_details/consumers_tab.py @@ -4,7 +4,7 @@ import bw2data as bd import bw_functional as bf -from activity_browser import actions, bwutils +from activity_browser import actions, bwutils, app from activity_browser.ui import widgets, icons @@ -71,8 +71,8 @@ def build_df(self, exchanges: list[bd.Edge]) -> pd.DataFrame: pd.DataFrame: The DataFrame containing the exchanges data. """ exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "output"]) - input_df = bwutils.AB_metadata.get_metadata(exc_df["input"].unique(), ["name", "type", "unit", "key"]) - output_df = bwutils.AB_metadata.get_metadata(exc_df["output"].unique(), ["name", "type", "key"]) + input_df = app.metadata.get_metadata(exc_df["input"].unique(), ["name", "type", "unit", "key"]) + output_df = app.metadata.get_metadata(exc_df["output"].unique(), ["name", "type", "key"]) df = exc_df.merge( input_df.rename({"name": "product", "type": "_product_type"}, axis="columns"), diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index da2e061b4..8bfe82cb3 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -8,8 +8,8 @@ import bw_functional as bf -from activity_browser import actions, bwutils -from activity_browser.bwutils import refresh_node, AB_metadata, database_is_locked, database_is_legacy +from activity_browser import actions, bwutils, app +from activity_browser.bwutils import refresh_node, database_is_locked, database_is_legacy from activity_browser.ui import widgets, icons, delegates @@ -138,7 +138,7 @@ def build_df(self, exchanges) -> pd.DataFrame: # Create a DataFrame from the exchanges exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "formula", "uncertainty type", "comment"]) exc_df["type"] = [x["type"] for x in exchanges] - act_df = AB_metadata.get_metadata(exc_df["input"].unique(), cols) + act_df = app.metadata.get_metadata(exc_df["input"].unique(), cols) # Merge the exchanges DataFrame with the metadata DataFrame df = exc_df.merge( @@ -263,7 +263,7 @@ def createEditor(self, parent, option, index): del setup[self.column] - self.matched = AB_metadata.match(**setup) + self.matched = app.metadata.match(**setup) combo = QtWidgets.QComboBox(parent) combo.addItems(list(self.matched.get(self.column, []).astype(str))) diff --git a/activity_browser/app/pages/calculation_setup/functional_unit_section.py b/activity_browser/app/pages/calculation_setup/functional_unit_section.py index 6068ffb7c..2e43a0749 100644 --- a/activity_browser/app/pages/calculation_setup/functional_unit_section.py +++ b/activity_browser/app/pages/calculation_setup/functional_unit_section.py @@ -4,9 +4,9 @@ import bw2data as bd import pandas as pd -from activity_browser import actions +from activity_browser import actions, app from activity_browser.ui import widgets, icons, delegates -from activity_browser.bwutils import AB_metadata, is_node_product +from activity_browser.bwutils import is_node_product class FunctionalUnitSection(QtWidgets.QWidget): @@ -44,7 +44,7 @@ def build_df(self): keys.append(key) amounts.append(amount) - act_df = AB_metadata.get_metadata(keys, cols) + act_df = app.metadata.get_metadata(keys, cols) act_df["amount"] = amounts act_df["_activity_key"] = keys act_df["_cs_name"] = self.calculation_setup_name @@ -53,7 +53,7 @@ def build_df(self): act_df["_processor_key"] = act_df["_processor_key"].fillna(act_df["_activity_key"]) # Retrieve metadata for unique processor keys, focusing on the "name" column. - processor_df = AB_metadata.get_metadata(act_df["_processor_key"].unique(), ["name"]) + processor_df = app.metadata.get_metadata(act_df["_processor_key"].unique(), ["name"]) # Flatten the index of the processor DataFrame to ensure compatibility with merging. processor_df.index = processor_df.index.to_flat_index() diff --git a/activity_browser/app/pages/impact_category_details/impact_category_details.py b/activity_browser/app/pages/impact_category_details/impact_category_details.py index 6b15b862b..88b143554 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_details.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_details.py @@ -6,7 +6,7 @@ from activity_browser import actions, app from activity_browser.ui import widgets, icons, delegates -from activity_browser.bwutils import AB_metadata, is_node_biosphere +from activity_browser.bwutils import is_node_biosphere from .impact_category_header import ImpactCategoryHeader @@ -71,7 +71,7 @@ def build_df(self): df["amount"] = df["data"].apply(lambda x: x if isinstance(x, (float, int)) else x.get("amount")) df["uncertainty"] = df["data"].apply(lambda x: 0 if isinstance(x, (float, int)) else x.get("uncertainty type")) - other = AB_metadata.dataframe[["id", "name", "categories", "database", "unit"]] + other = app.metadata.dataframe[["id", "name", "categories", "database", "unit"]] df = df.merge(other, left_on="id", right_on="id").rename(columns={"id": "_id", "data": "_cf"}) df["_impact_category_name"] = [self.name for i in range(len(df))] diff --git a/activity_browser/app/pages/lca_results/LCA_results.py b/activity_browser/app/pages/lca_results/LCA_results.py index 3272a2fde..3fd761e23 100644 --- a/activity_browser/app/pages/lca_results/LCA_results.py +++ b/activity_browser/app/pages/lca_results/LCA_results.py @@ -1514,7 +1514,7 @@ def key_to_metadata(self, key: tuple) -> list: format: [reference product, activity name, location, unit, database] """ - return list(bwutils.AB_metadata.get_metadata([key], ["reference product", "name", "location", "unit"]).iloc[0]) + [key[0]] + return list(app.metadata.get_metadata([key], ["reference product", "name", "location", "unit"]).iloc[0]) + [key[0]] def metadata_to_index(self, data: list) -> str: """Convert list to formatted index. diff --git a/activity_browser/app/pages/metadatastore.py b/activity_browser/app/pages/metadatastore.py index 92e830253..11f5ddf19 100644 --- a/activity_browser/app/pages/metadatastore.py +++ b/activity_browser/app/pages/metadatastore.py @@ -1,7 +1,7 @@ from qtpy import QtWidgets from activity_browser.ui import widgets, delegates -from activity_browser.bwutils import AB_metadata +from activity_browser.app import metadata, signals class MetaDataStorePage(QtWidgets.QWidget): @@ -9,7 +9,7 @@ def __init__(self, parent=None): super().__init__(parent) self.setObjectName("MetaDataStorePage") - self.model = MDSModel(self, AB_metadata.dataframe) + self.model = MDSModel(self, metadata.dataframe) self.view = MDSView(self) self.view.setModel(self.model) @@ -17,10 +17,10 @@ def __init__(self, parent=None): self.connect_signals() def connect_signals(self): - AB_metadata.synced.connect(self.sync) + signals.metadata.synced.connect(self.sync) def sync(self): - self.model.setDataFrame(AB_metadata.dataframe) + self.model.setDataFrame(metadata.dataframe) def build_layout(self): layout = QtWidgets.QVBoxLayout() diff --git a/activity_browser/app/pages/parameters/parameters_new.py b/activity_browser/app/pages/parameters/parameters_new.py index f2cc3ae3c..38fdc05a3 100644 --- a/activity_browser/app/pages/parameters/parameters_new.py +++ b/activity_browser/app/pages/parameters/parameters_new.py @@ -7,7 +7,7 @@ from activity_browser import app, actions from activity_browser.ui import widgets, icons, delegates -from activity_browser.bwutils import refresh_parameter, refresh_node, Parameter, database_is_locked, AB_metadata +from activity_browser.bwutils import refresh_parameter, refresh_node, Parameter, database_is_locked class ParametersPage(QtWidgets.QWidget): @@ -87,7 +87,7 @@ def connect_signals(self): """ Connects signals to their respective slots. """ - AB_metadata.synced.connect(self.sync) + app.signals.metadata.synced.connect(self.sync) app.signals.parameter.changed.connect(self.sync) app.signals.parameter.recalculated.connect(self.sync) app.signals.parameter.deleted.connect(self.sync) @@ -194,8 +194,8 @@ def build_exchanges_df(self) -> pd.DataFrame: output_key = exchange.get("output") # Get metadata from metadata store - input_meta = AB_metadata.get_metadata([input_key], ["name", "unit", "location", "database", "product"]).iloc[0] - output_meta = AB_metadata.get_metadata([output_key], ["name"]).iloc[0] + input_meta = app.metadata.get_metadata([input_key], ["name", "unit", "location", "database", "product"]).iloc[0] + output_meta = app.metadata.get_metadata([output_key], ["name"]).iloc[0] row = { "amount": exchange.get("amount"), diff --git a/activity_browser/app/panes/database_explorer.py b/activity_browser/app/panes/database_explorer.py index 358af7308..ea7e3ae3d 100644 --- a/activity_browser/app/panes/database_explorer.py +++ b/activity_browser/app/panes/database_explorer.py @@ -5,9 +5,8 @@ import bw2data as bd -from activity_browser.bwutils import AB_metadata from activity_browser.ui import widgets -from activity_browser.app import application, signals +from activity_browser.app import application, signals, metadata @@ -59,7 +58,7 @@ def __init__(self, db_name: str, parent=None): # connect signals signals.database.deleted.connect(self.deleteLater) signals.project.changed.connect(self.deleteLater) - AB_metadata.synced.connect(self.sync) + signals.metadata.synced.connect(self.sync) self.table_view.filtered.connect(self.search_error) def sync(self): @@ -69,7 +68,7 @@ def build_df(self) -> pd.DataFrame: import sqlite3 from bw2data.backends import sqlite3_lci_db - full_df = AB_metadata.get_database_metadata(self.database.name) + full_df = metadata.get_database_metadata(self.database.name) con = sqlite3.connect(sqlite3_lci_db._filepath) sql = f"SELECT output_code FROM exchangedataset WHERE output_database == '{self.database.name}'" diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index f2f3bbd4e..ea1af9264 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -10,7 +10,7 @@ from activity_browser import actions, ui, app from activity_browser.settings import project_settings from activity_browser.ui import core, widgets, delegates, icons -from activity_browser.bwutils import AB_metadata, database_is_locked, database_is_legacy +from activity_browser.bwutils import database_is_locked, database_is_legacy NODETYPES = { @@ -102,7 +102,7 @@ def build_layout(self): self.setLayout(layout) def connect_signals(self): - AB_metadata.synced.connect(self.on_metadata_changed) + app.signals.metadata.synced.connect(self.on_metadata_changed) app.signals.database.deleted.connect(self.on_database_deleted) self.table_view.filtered.connect(self.search_error) @@ -120,7 +120,7 @@ def update_loading_state(self): Updates the loading state based on whether primary metadata has loaded. Shows the loading indicator if primary data is still loading, otherwise shows the table. """ - if AB_metadata.loader.secondary_status == "done": + if app.metadata.loader.secondary_status == "done": # Show table view self.stacked_layout.setCurrentIndex(1) else: @@ -153,7 +153,7 @@ def build_df(self) -> pd.DataFrame: """ t = time() cols = ["name", "key", "processor", "product", "type", "unit", "location", "id", "categories", "properties"] - df = AB_metadata.get_database_metadata(self.database.name, cols) + df = app.metadata.get_database_metadata(self.database.name, cols) processors = set(df["processor"].dropna().unique()) df = df.drop(processors, errors="ignore") diff --git a/activity_browser/app/signals.py b/activity_browser/app/signals.py index 3bb1ae930..0e5081e94 100644 --- a/activity_browser/app/signals.py +++ b/activity_browser/app/signals.py @@ -1,7 +1,7 @@ from loguru import logger from time import time -from qtpy.QtCore import QObject, Signal, SignalInstance +from qtpy.QtCore import QObject, Signal, SignalInstance, QTimer from blinker import signal as blinker_signal @@ -50,6 +50,33 @@ class MetaSignals(QObject): calculation_setups_changed: SignalInstance = Signal(object, object) +class MetaDataSignals(QObject): + """Signals for MetaDataStore updates.""" + synced: SignalInstance = Signal(set, set, set) # added, updated, deleted + + def __init__(self, parent=None): + from activity_browser.bwutils.metadata import MetaDataStore + super().__init__(parent) + + self._metadata = MetaDataStore() + self._flusher = QTimer(self, interval=100) + self._flusher.timeout.connect(self._flush_metadata) + self._flusher.start() + + def _flush_metadata(self): + if not (self._metadata._added or self._metadata._updated or self._metadata._deleted): + return + + t = time() + self.synced.emit(self._metadata._added, self._metadata._updated, self._metadata._deleted) + + self._metadata._added.clear() + self._metadata._updated.clear() + self._metadata._deleted.clear() + + logger.debug(f"Metadatastore sync signal completed in {time() - t:.2f} seconds") + + class ABSignals(QObject): """Signals used for the Activity Browser should be defined here. While arguments can be passed to signals, it is good practice not to do this if possible. @@ -62,6 +89,7 @@ class ABSignals(QObject): database = DatabaseSignals() project = ProjectSignals() meta = MetaSignals() + metadata = MetaDataSignals() parameter = ParameterSignals() import_project = Signal() # Import a project diff --git a/activity_browser/bwutils/__init__.py b/activity_browser/bwutils/__init__.py index 0309bbe03..4c2f14a3e 100644 --- a/activity_browser/bwutils/__init__.py +++ b/activity_browser/bwutils/__init__.py @@ -9,7 +9,7 @@ from .commontasks import (refresh_node, refresh_node_or_none, refresh_parameter, refresh_edge, refresh_edge_or_none, parameters_in_scope, exchanges_to_sdf, database_is_locked, database_is_legacy, projects_by_last_opened, node_group, is_node_product, is_node_biosphere, is_node_process) -from .metadata import MetaDataStore +from .metadata import MetaDataStore # Class only, instance is in activity_browser.app.metadata from .montecarlo import MonteCarloLCA from .multilca import MLCA, Contributions from .pedigree import PedigreeMatrix diff --git a/activity_browser/bwutils/commontasks.py b/activity_browser/bwutils/commontasks.py index 2d50df02f..38058a96c 100644 --- a/activity_browser/bwutils/commontasks.py +++ b/activity_browser/bwutils/commontasks.py @@ -14,7 +14,6 @@ from functools import lru_cache -from .metadata import AB_metadata from .utils import Parameter @@ -165,8 +164,9 @@ def count_database_records(name: str) -> int: """To account for possible brightway database types that do not implement the __len__ method. """ + from activity_browser.app import metadata try: - return len(AB_metadata.dataframe.loc[name]) + return len(metadata.dataframe.loc[name]) except KeyError: return 0 @@ -381,12 +381,14 @@ def identify_activity_type(activity): def generate_copy_code(key: tuple) -> str: """Generate a new code to use when copying an activity""" + from activity_browser.app import metadata + db, code = key - metadata = AB_metadata.get_database_metadata(db) + meta = metadata.get_database_metadata(db) if "_copy" in code: code = code.split("_copy")[0] copies = ( - metadata["key"] + meta["key"] .apply(lambda x: x[1] if code in x[1] and "_copy" in x[1] else None) .dropna() .to_list() diff --git a/activity_browser/bwutils/exporters.py b/activity_browser/bwutils/exporters.py index b8014c4ab..9ec71cf36 100644 --- a/activity_browser/bwutils/exporters.py +++ b/activity_browser/bwutils/exporters.py @@ -5,11 +5,10 @@ from typing import Union import xlsxwriter +import bw2data as bd from bw2io.export.csv import reformat from bw2io.export.excel import CSVFormatter, create_valid_worksheet_name -from activity_browser.mod import bw2data as bd - from .importers import ABPackage from .pedigree import PedigreeMatrix diff --git a/activity_browser/bwutils/importers.py b/activity_browser/bwutils/importers.py index 96bafa27b..7eb428773 100644 --- a/activity_browser/bwutils/importers.py +++ b/activity_browser/bwutils/importers.py @@ -19,8 +19,7 @@ normalize_biosphere_names, normalize_units, set_code_by_activity_hash, strip_biosphere_exc_locations) - -from activity_browser.mod import bw2data as bd +import bw2data as bd from .errors import LinkingFailed from .strategies import (alter_database_name, csv_rewrite_product_key, diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 00ff3b04d..4a707abf7 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -1,38 +1,36 @@ import sqlite3 import pickle +import threading from multiprocessing import Pool from loguru import logger -from typing import Literal +from typing import Literal, Callable import pandas as pd import bw2data as bd from bw2data.backends import sqlite3_lci_db -from qtpy import QtCore - -from activity_browser.ui.core import threading - from .metadata import MetaDataStore from .fields import secondary_types, primary, secondary -class MDSLoader(QtCore.QObject): +class MDSLoader(): primary_status: Literal["idle", "loading", "done"] = "idle" secondary_status: Literal["idle", "loading", "done"] = "idle" def __init__(self, mds: MetaDataStore): - super().__init__(mds) - self.mds = mds self.connect_signals() def connect_signals(self): - from activity_browser import app - app.signals.project.changed.connect(self.on_project_changed) + from bw2data import signals + + # Connect to Brightway's project_changed signal + signals.project_changed.connect(self.on_project_changed) - def on_project_changed(self): + def on_project_changed(self, sender): + """Called when the Brightway project changes.""" self.load_project() def load_project(self): @@ -40,11 +38,13 @@ def load_project(self): self.primary_status = "loading" self.secondary_status = "loading" - # start loading threads - thread = SecondaryLoadThread(self) - thread.setObjectName("SecondaryLoadThread-MDSLoader") - thread.done.connect(self.secondary_load_project) - thread.start(databases=list(bd.databases), sqlite_db=str(sqlite3_lci_db._filepath)) + # start loading thread for secondary metadata + thread = SecondaryLoadThread( + databases=list(bd.databases), + sqlite_db=str(sqlite3_lci_db._filepath), + callback=self.secondary_load_project + ) + thread.start() # load primary metadata in the main thread self.primary_load_project() @@ -79,10 +79,13 @@ def secondary_load_project(self, secondary_df: pd.DataFrame, sqlite_db: str): self.secondary_status = "done" def load_database(self, database_name: str): - # start loading threads - thread = SecondaryLoadThread(self) - thread.done.connect(self.secondary_load_database) - thread.start(databases=[database_name], sqlite_db=str(sqlite3_lci_db._filepath)) + # start loading thread for secondary metadata + thread = SecondaryLoadThread( + databases=[database_name], + sqlite_db=str(sqlite3_lci_db._filepath), + callback=self.secondary_load_database + ) + thread.start() # load primary metadata in the main thread self.primary_load_database(database_name) @@ -132,21 +135,37 @@ def _fix_categories(self, df: pd.DataFrame): self.mds.dataframe[col] = self.mds.dataframe[col].cat.add_categories(categories) -class SecondaryLoadThread(threading.ABThread): - done: QtCore.SignalInstance = QtCore.Signal(pd.DataFrame, str) - - def run_safely(self, databases: list[str], sqlite_db: str): - with Pool() as pool: - args = [(sqlite_db, db, secondary) for db in databases] - results = pool.starmap(load, args) - - full_df = pd.DataFrame() - for df in results: - if df is None or df.empty: - continue - full_df = pd.concat([full_df, df]) - - self.done.emit(full_df, sqlite_db) +class SecondaryLoadThread(threading.Thread): + """Thread for loading secondary metadata using multiprocessing Pool.""" + + def __init__(self, databases: list[str], sqlite_db: str, callback: Callable): + super().__init__(daemon=True) + self.databases = databases + self.sqlite_db = sqlite_db + self.callback = callback + self.result_df = None + + def run(self): + """Execute the loading in a background thread.""" + try: + with Pool() as pool: + args = [(self.sqlite_db, db, secondary) for db in self.databases] + results = pool.starmap(load, args) + + full_df = pd.DataFrame() + for df in results: + if df is None or df.empty: + continue + full_df = pd.concat([full_df, df]) + + # Store result and call callback + self.result_df = full_df + self.callback(full_df, self.sqlite_db) + + except Exception as e: + logger.error(f"Error loading secondary metadata: {e}") + # Call callback with empty dataframe on error + self.callback(pd.DataFrame(), self.sqlite_db) def load(fp: str, database_name: str, fields: list[str]): diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 19c1173ee..8810b9d60 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -4,21 +4,22 @@ import pandas as pd -from qtpy.QtCore import QObject, Signal, SignalInstance, QTimer - from .fields import all, all_types -class MetaDataStore(QObject): - synced: SignalInstance = Signal(set, set, set) # added, updated, deleted - - def __init__(self, parent=None): - from activity_browser import app +class MetaDataStore(): + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): from .loader import MDSLoader from .updater import MDSUpdater - super().__init__(parent) - self._dataframe = pd.DataFrame() self._added: set[tuple[str, str]] = set() @@ -27,9 +28,6 @@ def __init__(self, parent=None): self.loader = MDSLoader(self) self.updater = MDSUpdater(self) - self.flusher: QTimer | None = None - - self.moveToThread(app.application.thread()) @property def dataframe(self) -> pd.DataFrame: @@ -66,22 +64,6 @@ def register_mutation(self, key: tuple[str, str], action: Literal["add", "update else: raise ValueError(f"Unknown action: {action}") - if not self.flusher: - self.flusher = QTimer(self, interval=100) - self.flusher.timeout.connect(self.flush_mutations) - self.flusher.start() - - def flush_mutations(self): - if not (self._added or self._updated or self._deleted): - return - - t = time() - self.synced.emit(self._added, self._updated, self._deleted) - - self._added.clear(), self._updated.clear(), self._deleted.clear() - - logger.debug(f"Metadatastore sync signal completed in {time() - t:.2f} seconds") - def match(self, **kwargs: dict[str, str]) -> pd.DataFrame: """Return a slice of the dataframe matching the criteria. """ diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index 6960bbeae..c7a021c58 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -3,31 +3,36 @@ import pandas as pd import numpy as np -from qtpy import QtCore - from .metadata import MetaDataStore from .fields import primary, secondary, all_types -class MDSUpdater(QtCore.QObject): +class MDSUpdater(): def __init__(self, mds: MetaDataStore): - super().__init__(mds) - self.mds = mds self.connect_signals() def connect_signals(self): - from activity_browser.app import signals - signals.node.changed.connect(self.on_node_changed) - signals.node.deleted.connect(self.on_node_deleted) - - signals.meta.databases_changed.connect(self.on_database_changed) - signals.database.deleted.connect(self.on_database_changed) + from bw2data import signals + from bw2data.meta import databases + + # Connect to Brightway signals + signals.signaleddataset_on_save.connect(self.on_signaleddataset_save) + signals.signaleddataset_on_delete.connect(self.on_signaleddataset_delete) + signals.on_database_delete.connect(self.on_database_deleted_bw) + databases._save_signal.connect(self.on_databases_metadata_change) # callbacks - def on_node_changed(self, new, old): + def on_signaleddataset_save(self, sender, old, new): + """Called when a dataset is created or modified in Brightway.""" + from bw2data.backends import ActivityDataset + + # Only process ActivityDataset (nodes), not exchanges or parameters + if not isinstance(new, ActivityDataset): + return + node_data = {f: getattr(new, f) for f in primary} node_data = node_data | {f: new.data.get(f, np.NaN) for f in secondary} node_data["key"] = new.key @@ -38,12 +43,29 @@ def on_node_changed(self, new, old): else: self.add_node(node_data) - def on_node_deleted(self, ds): + def on_signaleddataset_delete(self, sender, old): + """Called when a dataset is deleted in Brightway.""" + from bw2data.backends import ActivityDataset + + # Only process ActivityDataset (nodes), not exchanges or parameters + if not isinstance(old, ActivityDataset): + return + try: + # Create a Series with the key to match the delete_node signature + ds = pd.Series({"key": old.key}, name=old.key) self.delete_node(ds) except KeyError: pass + def on_database_deleted_bw(self, sender, name): + """Called when a database is deleted in Brightway.""" + self.delete_database(name) + + def on_databases_metadata_change(self, sender, old, new): + """Called when the databases metadata changes (e.g., new database added).""" + self.on_database_changed() + def on_database_changed(self) -> None: databases = databases_in_sqlite() diff --git a/activity_browser/bwutils/montecarlo.py b/activity_browser/bwutils/montecarlo.py index 7e3c48f08..c6b96a952 100644 --- a/activity_browser/bwutils/montecarlo.py +++ b/activity_browser/bwutils/montecarlo.py @@ -3,17 +3,11 @@ from typing import Optional, Union from loguru import logger +import bw2data as bd import bw2calc as bc import bw2data as bd import numpy as np import pandas as pd -from stats_arrays import MCRandomNumberGenerator - -from activity_browser.mod import bw2data as bd - -from .manager import MonteCarloParameterManager - - class MonteCarloLCA(object): diff --git a/activity_browser/bwutils/multilca.py b/activity_browser/bwutils/multilca.py index 0a46e91f2..abaff80c6 100644 --- a/activity_browser/bwutils/multilca.py +++ b/activity_browser/bwutils/multilca.py @@ -3,17 +3,16 @@ from typing import Iterable, Optional, Union from loguru import logger +import bw2data as bd import bw2calc as bc import numpy as np import pandas as pd from qtpy.QtWidgets import QApplication, QMessageBox -from activity_browser.mod import bw2data as bd from activity_browser.mod.bw2analyzer import ABContributionAnalysis from .commontasks import wrap_text from .errors import ReferenceFlowValueError -from .metadata import AB_metadata ca = ABContributionAnalysis() @@ -346,14 +345,16 @@ class Contributions(object): DEFAULT_EF_AGGREGATES = ["none"] + DEFAULT_EF_FIELDS def __init__(self, mlca): + from activity_browser.app import metadata + if not isinstance(mlca, MLCA): raise ValueError("Must pass an MLCA object. Passed:", type(mlca)) self.mlca = mlca # Set default metadata keys (those not in the dataframe will be eliminated) - self.act_fields = [fn for fn in self.DEFAULT_ACT_FIELDS if fn in AB_metadata.dataframe.columns] - self.ef_fields = [fn for fn in self.DEFAULT_EF_FIELDS if fn in AB_metadata.dataframe.columns] + self.act_fields = [fn for fn in self.DEFAULT_ACT_FIELDS if fn in metadata.dataframe.columns] + self.ef_fields = [fn for fn in self.DEFAULT_EF_FIELDS if fn in metadata.dataframe.columns] # Specific datastructures for retrieving relevant MLCA data # inventory: inventory, reverse index, metadata keys, metadata fields @@ -503,10 +504,10 @@ def get_labels( translated_keys.append(k) elif isinstance(k, str): translated_keys.append(k) - elif k in AB_metadata.dataframe.index: + elif k in metadata.dataframe.index: translated_keys.append( separator.join( - [str(l) for l in list(AB_metadata.get_metadata(k, fields))] + [str(l) for l in list(metadata.get_metadata(k, fields))] ) ) else: @@ -553,11 +554,11 @@ def join_df_with_metadata( df.index.names = ["database", "code"] # get metadata for rows - keys = [k for k in df.index if k in AB_metadata.dataframe.index] - metadata = AB_metadata.get_metadata(keys, x_fields).astype(object) + keys = [k for k in df.index if k in metadata.dataframe.index] + meta = metadata.get_metadata(keys, x_fields).astype(object) # join data with metadata - joined = metadata.join(df, how="outer") + joined = meta.join(df, how="outer") if special_keys: # replace index keys with labels @@ -651,7 +652,7 @@ def _build_inventory( data.columns = Contributions.get_labels(columns, max_length=30) data = pd.merge( - AB_metadata.dataframe[fields], data, right_index=True, left_on="id", how="right" + metadata.dataframe[fields], data, right_index=True, left_on="id", how="right" ) data.reset_index(inplace=True, drop=True) @@ -766,9 +767,9 @@ def aggregate_by_parameters( df = pd.DataFrame(contributions).T columns = list(range(contributions.shape[0])) df.index = rev_index.values() - metadata = AB_metadata.dataframe.loc[AB_metadata.dataframe["id"].isin(keys), fields + ["id"]] + meta = metadata.dataframe.loc[metadata.dataframe["id"].isin(keys), fields + ["id"]] - joined = metadata.merge(df, left_on="id", right_index=True, how="left") + joined = meta.merge(df, left_on="id", right_index=True, how="left") joined.reset_index(inplace=True, drop=True) grouped = joined.groupby(parameters, observed=False) aggregated = grouped[columns].sum() diff --git a/activity_browser/bwutils/sensitivity_analysis.py b/activity_browser/bwutils/sensitivity_analysis.py index 4dec78461..8dfbed818 100644 --- a/activity_browser/bwutils/sensitivity_analysis.py +++ b/activity_browser/bwutils/sensitivity_analysis.py @@ -13,11 +13,10 @@ import bw2calc as bc import numpy as np import pandas as pd +import bw2data as bd from SALib.analyze import delta -from activity_browser.mod import bw2data as bd - -from ..settings import ab_settings +# from ..settings import ab_settings from .montecarlo import MonteCarloLCA, perform_MonteCarlo_LCA try: @@ -474,11 +473,15 @@ def get_save_name(self): return save_name def export_GSA_output(self): + from ..settings import ab_settings + save_name = "gsa_output_" + self.get_save_name() self.df_final.to_excel(os.path.join(ab_settings.data_dir, save_name)) def export_GSA_input(self): """Export the input data to the GSA with a human readible index""" + from ..settings import ab_settings + X_with_index = pd.DataFrame(self.X.T, index=self.metadata.index) save_name = "gsa_input_" + self.get_save_name() X_with_index.to_excel(os.path.join(ab_settings.data_dir, save_name)) diff --git a/activity_browser/bwutils/superstructure/dataframe.py b/activity_browser/bwutils/superstructure/dataframe.py index 8ecf63d6e..450b242f4 100644 --- a/activity_browser/bwutils/superstructure/dataframe.py +++ b/activity_browser/bwutils/superstructure/dataframe.py @@ -10,13 +10,15 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication, QPushButton +from activity_browser.bwutils.metadata import MetaDataStore from ..errors import ScenarioDatabaseNotFoundError -from ..metadata import AB_metadata from ..utils import Index from .activities import data_from_index from .file_dialogs import ABPopup from .utils import SUPERSTRUCTURE +metadata = MetaDataStore() + def superstructure_from_arrays( samples: np.ndarray, indices: np.ndarray, names: List[str] = None @@ -123,7 +125,7 @@ def arrays_from_indexed_superstructure( ) -> Tuple[np.ndarray, np.ndarray]: result = np.zeros(df.shape[0], dtype=object) - meta = AB_metadata.dataframe["id"] + meta = metadata.dataframe["id"] meta.index = meta.index.to_flat_index() id_df = pd.merge(df, meta, left_on="input", right_index=True).rename(columns={"id":"input_id"}) @@ -285,8 +287,8 @@ def exchange_replace_database( changes = ["from database", "from key", "to database", "to key"] # Load all required databases into the metadata - AB_metadata.add_metadata(replacements.values()) - metadata = AB_metadata.dataframe + metadata.add_metadata(replacements.values()) + meta = metadata.dataframe for idx in df.index: df.loc[idx, changes] = exchange_replace_database( diff --git a/activity_browser/bwutils/superstructure/file_dialogs.py b/activity_browser/bwutils/superstructure/file_dialogs.py index 34bbf6b54..1ba5906dd 100644 --- a/activity_browser/bwutils/superstructure/file_dialogs.py +++ b/activity_browser/bwutils/superstructure/file_dialogs.py @@ -1,7 +1,6 @@ import pandas as pd from qtpy import QtCore, QtWidgets -from ...ui.icons import qicons """ The basic premise of this module is to contain a series of different popup menus that will allow the user @@ -227,6 +226,8 @@ def abQuestion(title, message, button1, button2): An ABPopup instance that provides the basic format and dialog for the popup window. Further manipulation of the object and execution (via .exec_()) is performed upon instantiation """ + from ...ui.icons import qicons + obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() obj.setWindowTitle(title) @@ -270,6 +271,8 @@ def abWarning(title, message, button1, button2=None, default=1): An ABPopup instance that provides the basic format and dialog for the popup window to provide a warning. Further manipulation of the object and execution (via .exec_()) is performed upon instantiation """ + from ...ui.icons import qicons + obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() obj.setWindowTitle(title) @@ -320,6 +323,8 @@ def abCritical(title, message, button1, button2=None, default=1): An ABPopup instance that provides the basic format and dialog for the popup window to provide a warning. Further manipulation of the object and execution (via .exec_()) is performed upon instantiation """ + from ...ui.icons import qicons + obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() obj.setWindowTitle(title) diff --git a/activity_browser/bwutils/superstructure/mlca.py b/activity_browser/bwutils/superstructure/mlca.py index c5e5e8810..89396263a 100644 --- a/activity_browser/bwutils/superstructure/mlca.py +++ b/activity_browser/bwutils/superstructure/mlca.py @@ -3,15 +3,16 @@ import numpy as np import pandas as pd +import bw2data as bd from qtpy.QtWidgets import QPushButton from activity_browser.mod import bw2data as bd -from activity_browser.bwutils import AB_metadata from ..commontasks import format_activity_label from ..errors import ScenarioExchangeNotFoundError from ..multilca import MLCA, Contributions from ..utils import Index +from ..metadata import MetaDataStore from .dataframe import (arrays_from_indexed_superstructure, filter_databases_indexed_superstructure, scenario_names_from_df) @@ -22,6 +23,7 @@ except ModuleNotFoundError: pass # removed in bw25 +metadata = MetaDataStore() class SuperstructureMLCA(MLCA): """Subclass of the `MLCA` class which adds another dimension in the form diff --git a/activity_browser/bwutils/utils.py b/activity_browser/bwutils/utils.py index 95f972893..61bf76791 100644 --- a/activity_browser/bwutils/utils.py +++ b/activity_browser/bwutils/utils.py @@ -4,7 +4,6 @@ import numpy as np import peewee as pw -from stats_arrays import UncertaintyBase import bw2data as bd from bw2data.backends import ActivityDataset, ExchangeDataset diff --git a/activity_browser/app/application.py b/activity_browser/ui/core/application.py similarity index 92% rename from activity_browser/app/application.py rename to activity_browser/ui/core/application.py index 90b0d58c6..45b0be424 100644 --- a/activity_browser/app/application.py +++ b/activity_browser/ui/core/application.py @@ -8,15 +8,22 @@ from activity_browser.static import fonts, icons - - class ABApplication(QtWidgets.QApplication): _main_window = None - _controllers = None + _instance = None windows = [] + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance def __init__(self, *args, **kwargs): + if self._initialized: + return + QtCore.QCoreApplication.setAttribute(Qt.AA_ShareOpenGLContexts, True) QtCore.QCoreApplication.setAttribute(Qt.AA_UseSoftwareOpenGL) @@ -29,6 +36,8 @@ def __init__(self, *args, **kwargs): if PYSIDE6: self.pyside6_setup() + + self._initialized = True def add_fonts(self): QFontDatabase.addApplicationFont(fonts.__path__[0] + "/mono.ttf") diff --git a/activity_browser/ui/web/tree_navigator.py b/activity_browser/ui/web/tree_navigator.py index 83fb4778e..5d657bef7 100644 --- a/activity_browser/ui/web/tree_navigator.py +++ b/activity_browser/ui/web/tree_navigator.py @@ -25,7 +25,6 @@ from activity_browser.utils import get_base_path from .base import BaseGraph, BaseNavigatorWidget from ..widgets.combobox import CheckableComboBox -from ...bwutils import AB_metadata from ...bwutils.commontasks import identify_activity_type diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index 6072f2f47..67a2dc17e 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -1,3 +1,4 @@ +from .plot import ABPlot from .abstract_pane import ABAbstractPane from .comparison_switch import SwitchComboBox from .cutoff_menu import CutoffMenu @@ -20,5 +21,4 @@ from .central import CentralTabWidget from .menu import ABMenu from .drop_overlay import ABDropOverlay -from .plot import ABPlot from .tree_view import ABNewTreeView diff --git a/activity_browser/ui/widgets/plot.py b/activity_browser/ui/widgets/plot.py index 9b17ad830..ecef4dbf1 100644 --- a/activity_browser/ui/widgets/plot.py +++ b/activity_browser/ui/widgets/plot.py @@ -1,13 +1,7 @@ -from loguru import logger - from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg from matplotlib.figure import Figure from qtpy import QtWidgets -from activity_browser.utils import savefilepath - - - class ABPlot(QtWidgets.QWidget): ALL_FILTER = "All Files (*.*)" @@ -45,6 +39,8 @@ def get_canvas_size_in_inches(self): def to_png(self): """Export to .png format.""" + from activity_browser.utils import savefilepath + filepath = savefilepath( default_file_name=self.plot_name, file_filter=self.PNG_FILTER ) @@ -54,7 +50,9 @@ def to_png(self): self.figure.savefig(filepath) def to_svg(self): - """Export to .svg format.""" + """Export to .svg format.""" + from activity_browser.utils import savefilepath + filepath = savefilepath( default_file_name=self.plot_name, file_filter=self.SVG_FILTER ) From 2d614d312b91560ddcdd9eeab4b762f215a54b46 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 6 Nov 2025 13:06:46 +0100 Subject: [PATCH 092/267] Refactor import statements to use commontasks module for better organization --- .../actions/activity/activity_duplicate_to_db.py | 2 +- .../actions/calculation_setup/cs_new.py | 2 +- .../actions/parameter/parameter_modify.py | 3 ++- .../actions/parameter/parameter_new_automatic.py | 2 +- .../parameter/parameter_new_from_parameter.py | 2 +- .../actions/parameter/parameter_rename.py | 3 ++- activity_browser/app/__init__.py | 2 +- .../app/pages/activity_details/data_tab.py | 2 +- .../app/pages/activity_details/exchanges_tab.py | 2 +- .../app/pages/activity_details/parameters_tab.py | 4 ++-- .../calculation_setup/functional_unit_section.py | 2 +- .../impact_category_details.py | 2 +- .../app/pages/parameters/parameters_new.py | 7 ++++--- activity_browser/app/pages/welcome.py | 2 +- activity_browser/app/panes/database_products.py | 4 ++-- activity_browser/bwutils/__init__.py | 15 --------------- activity_browser/bwutils/multilca.py | 8 ++++++-- .../bwutils/superstructure/dataframe.py | 6 +++--- activity_browser/ui/dialogs/uncertainty.py | 6 ++---- 19 files changed, 33 insertions(+), 43 deletions(-) diff --git a/activity_browser/actions/activity/activity_duplicate_to_db.py b/activity_browser/actions/activity/activity_duplicate_to_db.py index bf7a9ae83..b1d2549ac 100644 --- a/activity_browser/actions/activity/activity_duplicate_to_db.py +++ b/activity_browser/actions/activity/activity_duplicate_to_db.py @@ -6,7 +6,7 @@ import bw_functional as bf from activity_browser.app import application -from activity_browser.bwutils import refresh_node +from activity_browser.bwutils.commontasks import refresh_node from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from bw_functional import Product diff --git a/activity_browser/actions/calculation_setup/cs_new.py b/activity_browser/actions/calculation_setup/cs_new.py index 834b2eab9..81cc79156 100644 --- a/activity_browser/actions/calculation_setup/cs_new.py +++ b/activity_browser/actions/calculation_setup/cs_new.py @@ -6,7 +6,7 @@ from activity_browser import app, actions from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils import refresh_node +from activity_browser.bwutils.commontasks import refresh_node from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/parameter/parameter_modify.py b/activity_browser/actions/parameter/parameter_modify.py index 8686011c2..3d888605a 100644 --- a/activity_browser/actions/parameter/parameter_modify.py +++ b/activity_browser/actions/parameter/parameter_modify.py @@ -5,7 +5,8 @@ from peewee import DoesNotExist from activity_browser.ui.icons import qicons -from activity_browser.bwutils import refresh_parameter, Parameter +from activity_browser.bwutils.commontasks import refresh_parameter +from activity_browser.bwutils.utils import Parameter from activity_browser.actions.base import ABAction, exception_dialogs from .parameter_rename import ParameterRename diff --git a/activity_browser/actions/parameter/parameter_new_automatic.py b/activity_browser/actions/parameter/parameter_new_automatic.py index b60ff432b..ed141c306 100644 --- a/activity_browser/actions/parameter/parameter_new_automatic.py +++ b/activity_browser/actions/parameter/parameter_new_automatic.py @@ -4,7 +4,7 @@ from qtpy import QtWidgets from activity_browser import app -from activity_browser.bwutils import refresh_node +from activity_browser.bwutils.commontasks import refresh_node from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from bw2data.parameters import ActivityParameter diff --git a/activity_browser/actions/parameter/parameter_new_from_parameter.py b/activity_browser/actions/parameter/parameter_new_from_parameter.py index fcbb1ac14..52fe1569c 100644 --- a/activity_browser/actions/parameter/parameter_new_from_parameter.py +++ b/activity_browser/actions/parameter/parameter_new_from_parameter.py @@ -1,7 +1,7 @@ from ast import literal_eval from activity_browser.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils import Parameter +from activity_browser.bwutils.utils import Parameter from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, parameters from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/parameter/parameter_rename.py b/activity_browser/actions/parameter/parameter_rename.py index 7932abb02..6172dcf55 100644 --- a/activity_browser/actions/parameter/parameter_rename.py +++ b/activity_browser/actions/parameter/parameter_rename.py @@ -5,7 +5,8 @@ from activity_browser import app from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -from activity_browser.bwutils import Parameter, refresh_parameter +from activity_browser.bwutils.utils import Parameter +from activity_browser.bwutils.commontasks import refresh_parameter class ParameterRename(ABAction): diff --git a/activity_browser/app/__init__.py b/activity_browser/app/__init__.py index ad7924512..aac9ff7eb 100644 --- a/activity_browser/app/__init__.py +++ b/activity_browser/app/__init__.py @@ -12,5 +12,5 @@ main_window = MainWindow() application.main_window = main_window -from activity_browser.bwutils import MetaDataStore +from activity_browser.bwutils.metadata import MetaDataStore metadata = MetaDataStore() diff --git a/activity_browser/app/pages/activity_details/data_tab.py b/activity_browser/app/pages/activity_details/data_tab.py index 1e331f01c..729d32534 100644 --- a/activity_browser/app/pages/activity_details/data_tab.py +++ b/activity_browser/app/pages/activity_details/data_tab.py @@ -5,7 +5,7 @@ import bw_functional as bf from activity_browser import actions -from activity_browser.bwutils import refresh_node, database_is_locked +from activity_browser.bwutils.commontasks import refresh_node, database_is_locked from activity_browser.ui import widgets, delegates diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index 8bfe82cb3..8ec62e968 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -9,7 +9,7 @@ import bw_functional as bf from activity_browser import actions, bwutils, app -from activity_browser.bwutils import refresh_node, database_is_locked, database_is_legacy +from activity_browser.bwutils.commontasks import refresh_node, database_is_locked, database_is_legacy from activity_browser.ui import widgets, icons, delegates diff --git a/activity_browser/app/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py index 4a643055d..6434ef416 100644 --- a/activity_browser/app/pages/activity_details/parameters_tab.py +++ b/activity_browser/app/pages/activity_details/parameters_tab.py @@ -5,8 +5,8 @@ from activity_browser import app, actions from activity_browser.ui import widgets, icons, delegates -from activity_browser.bwutils import refresh_node, refresh_parameter, parameters_in_scope, Parameter, database_is_locked -from activity_browser.bwutils import node_group +from activity_browser.bwutils.commontasks import refresh_node, refresh_parameter, parameters_in_scope, database_is_locked, node_group +from activity_browser.bwutils.utils import Parameter class ParametersTab(QtWidgets.QWidget): diff --git a/activity_browser/app/pages/calculation_setup/functional_unit_section.py b/activity_browser/app/pages/calculation_setup/functional_unit_section.py index 2e43a0749..f896c5b14 100644 --- a/activity_browser/app/pages/calculation_setup/functional_unit_section.py +++ b/activity_browser/app/pages/calculation_setup/functional_unit_section.py @@ -6,7 +6,7 @@ from activity_browser import actions, app from activity_browser.ui import widgets, icons, delegates -from activity_browser.bwutils import is_node_product +from activity_browser.bwutils.commontasks import is_node_product class FunctionalUnitSection(QtWidgets.QWidget): diff --git a/activity_browser/app/pages/impact_category_details/impact_category_details.py b/activity_browser/app/pages/impact_category_details/impact_category_details.py index 88b143554..9ab124a22 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_details.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_details.py @@ -6,7 +6,7 @@ from activity_browser import actions, app from activity_browser.ui import widgets, icons, delegates -from activity_browser.bwutils import is_node_biosphere +from activity_browser.bwutils.commontasks import is_node_biosphere from .impact_category_header import ImpactCategoryHeader diff --git a/activity_browser/app/pages/parameters/parameters_new.py b/activity_browser/app/pages/parameters/parameters_new.py index 38fdc05a3..365cfcdc5 100644 --- a/activity_browser/app/pages/parameters/parameters_new.py +++ b/activity_browser/app/pages/parameters/parameters_new.py @@ -7,7 +7,8 @@ from activity_browser import app, actions from activity_browser.ui import widgets, icons, delegates -from activity_browser.bwutils import refresh_parameter, refresh_node, Parameter, database_is_locked +from activity_browser.bwutils.commontasks import refresh_parameter, refresh_node, database_is_locked +from activity_browser.bwutils.utils import Parameter class ParametersPage(QtWidgets.QWidget): @@ -275,7 +276,7 @@ def scoped_parameters(self) -> dict[str, Parameter]: Returns: dict: The parameters in scope. """ - from activity_browser.bwutils import parameters_in_scope + from activity_browser.bwutils.commontasks import parameters_in_scope return parameters_in_scope(parameter=self["_parameter"]) @property @@ -576,7 +577,7 @@ def scoped_parameters(self) -> dict[str, Parameter]: Returns: dict: The parameters in scope. """ - from activity_browser.bwutils import parameters_in_scope + from activity_browser.bwutils.commontasks import parameters_in_scope return parameters_in_scope(node=self["_exchange"].output) def flags(self, col: int, key: str): diff --git a/activity_browser/app/pages/welcome.py b/activity_browser/app/pages/welcome.py index 27e305106..bd5988fe9 100644 --- a/activity_browser/app/pages/welcome.py +++ b/activity_browser/app/pages/welcome.py @@ -4,7 +4,7 @@ from activity_browser import actions, app from activity_browser.static import startscreen -from activity_browser.bwutils import projects_by_last_opened +from activity_browser.bwutils.commontasks import projects_by_last_opened class WelcomePage(QtWidgets.QWidget): diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index ea1af9264..0e83e3268 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -10,7 +10,7 @@ from activity_browser import actions, ui, app from activity_browser.settings import project_settings from activity_browser.ui import core, widgets, delegates, icons -from activity_browser.bwutils import database_is_locked, database_is_legacy +from activity_browser.bwutils.commontasks import database_is_locked, database_is_legacy NODETYPES = { @@ -258,7 +258,7 @@ class ContextMenu(ui.widgets.ABMenu): @staticmethod def get_functional_unit_amount(key): - from activity_browser.bwutils import refresh_node + from activity_browser.bwutils.commontasks import refresh_node excs = list(refresh_node(key).upstream(["production"])) exc = excs[0] if len(excs) == 1 else {} return exc.get("amount", 1.0) diff --git a/activity_browser/bwutils/__init__.py b/activity_browser/bwutils/__init__.py index 4c2f14a3e..2cdbf9dd5 100644 --- a/activity_browser/bwutils/__init__.py +++ b/activity_browser/bwutils/__init__.py @@ -3,19 +3,4 @@ bwutils is a collection of methods that build upon brightway2 and are generic enough to provide here so that we avoid re-typing the same code in different parts of the Activity Browser. """ -import bw_functional -from .commontasks import cleanup_deleted_bw_projects as cleanup -from .commontasks import (refresh_node, refresh_node_or_none, refresh_parameter, refresh_edge, refresh_edge_or_none, - parameters_in_scope, exchanges_to_sdf, database_is_locked, database_is_legacy, projects_by_last_opened, - node_group, is_node_product, is_node_biosphere, is_node_process) -from .metadata import MetaDataStore # Class only, instance is in activity_browser.app.metadata -from .montecarlo import MonteCarloLCA -from .multilca import MLCA, Contributions -from .pedigree import PedigreeMatrix -from .sensitivity_analysis import GlobalSensitivityAnalysis -from .superstructure import SuperstructureContributions, SuperstructureMLCA -from .uncertainty import (CFUncertaintyInterface, ExchangeUncertaintyInterface, - ParameterUncertaintyInterface, - get_uncertainty_interface) -from .utils import Parameter diff --git a/activity_browser/bwutils/multilca.py b/activity_browser/bwutils/multilca.py index abaff80c6..7096de158 100644 --- a/activity_browser/bwutils/multilca.py +++ b/activity_browser/bwutils/multilca.py @@ -7,12 +7,14 @@ import bw2calc as bc import numpy as np import pandas as pd -from qtpy.QtWidgets import QApplication, QMessageBox from activity_browser.mod.bw2analyzer import ABContributionAnalysis from .commontasks import wrap_text from .errors import ReferenceFlowValueError +from .metadata import MetaDataStore + +metadata = MetaDataStore() ca = ABContributionAnalysis() @@ -109,6 +111,8 @@ class MLCA(object): """ def __init__(self, cs_name: str, lca_class: bc.LCA = bc.LCA): + from qtpy.QtWidgets import QApplication, QMessageBox + try: cs = bd.calculation_setups[cs_name] except KeyError: @@ -802,7 +806,7 @@ def _correct_method_index(self, mthd_indx: list) -> dict: conv_dict[mthd] = v return conv_dict - def _contribution_index_cols(self, **kwargs) -> (dict, Optional[Iterable]): + def _contribution_index_cols(self, **kwargs) -> tuple[dict, Optional[Iterable]]: if kwargs.get("method") is not None: return self.mlca.fu_index, self.act_fields return self._correct_method_index(self.mlca.methods), None diff --git a/activity_browser/bwutils/superstructure/dataframe.py b/activity_browser/bwutils/superstructure/dataframe.py index 450b242f4..d61a50979 100644 --- a/activity_browser/bwutils/superstructure/dataframe.py +++ b/activity_browser/bwutils/superstructure/dataframe.py @@ -47,7 +47,7 @@ def superstructure_from_arrays( def superstructure_from_scenario_exchanges(scenarios: dict[str, dict[int, float]]): - from activity_browser.bwutils import exchanges_to_sdf + from activity_browser.bwutils.commontasks import exchanges_to_sdf from bw2data import Edge scenarios = transpose_scenarios_to_exchange_ids(scenarios) @@ -63,7 +63,7 @@ def superstructure_from_scenario_exchanges(scenarios: dict[str, dict[int, float] def regular_exchange_to_sdf(exchange_id: int, scenarios: dict[str, float]): - from activity_browser.bwutils import exchanges_to_sdf + from activity_browser.bwutils.commontasks import exchanges_to_sdf exc = bd.Edge(bd.Edge.ORMDataset.get_by_id(exchange_id)).as_dict() df = exchanges_to_sdf([exc]) @@ -75,7 +75,7 @@ def regular_exchange_to_sdf(exchange_id: int, scenarios: dict[str, float]): def mf_exchange_to_sdf(exchange_id: int, scenarios: dict[str, float]): - from activity_browser.bwutils import exchanges_to_sdf + from activity_browser.bwutils.commontasks import exchanges_to_sdf exc = bf.MFExchange(bf.MFExchange.ORMDataset.get_by_id(exchange_id)) diff --git a/activity_browser/ui/dialogs/uncertainty.py b/activity_browser/ui/dialogs/uncertainty.py index 12e88e927..a35a4dcdf 100644 --- a/activity_browser/ui/dialogs/uncertainty.py +++ b/activity_browser/ui/dialogs/uncertainty.py @@ -10,10 +10,8 @@ from activity_browser import actions, app from activity_browser.ui.widgets.plot import ABPlot - -from ...bwutils import PedigreeMatrix, get_uncertainty_interface -from ...bwutils.uncertainty import EMPTY_UNCERTAINTY - +from activity_browser.bwutils.pedigree import PedigreeMatrix +from activity_browser.bwutils.uncertainty import get_uncertainty_interface, EMPTY_UNCERTAINTY From dd0e1a717d85376c08fb63f852a90e85bc3350f1 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 6 Nov 2025 13:18:05 +0100 Subject: [PATCH 093/267] Refactor import statements for bw2data and sqlite3_lci_db to improve code organization --- activity_browser/bwutils/metadata/loader.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 4a707abf7..cf1ae0490 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -6,15 +6,11 @@ from typing import Literal, Callable import pandas as pd -import bw2data as bd -from bw2data.backends import sqlite3_lci_db from .metadata import MetaDataStore from .fields import secondary_types, primary, secondary - - class MDSLoader(): primary_status: Literal["idle", "loading", "done"] = "idle" secondary_status: Literal["idle", "loading", "done"] = "idle" @@ -34,6 +30,8 @@ def on_project_changed(self, sender): self.load_project() def load_project(self): + import bw2data as bd + from bw2data.backends import sqlite3_lci_db # set statuses self.primary_status = "loading" self.secondary_status = "loading" @@ -50,6 +48,8 @@ def load_project(self): self.primary_load_project() def primary_load_project(self): + from bw2data.backends import sqlite3_lci_db + with sqlite3.connect(sqlite3_lci_db._filepath) as con: fields = ', '.join(primary[1:]) # Exclude 'key' as it's constructed primary_df = pd.read_sql(f"SELECT {fields} FROM activitydataset", con) @@ -66,6 +66,8 @@ def primary_load_project(self): self.primary_status = "done" def secondary_load_project(self, secondary_df: pd.DataFrame, sqlite_db: str): + from bw2data.backends import sqlite3_lci_db + if sqlite_db != str(sqlite3_lci_db._filepath): return @@ -79,6 +81,8 @@ def secondary_load_project(self, secondary_df: pd.DataFrame, sqlite_db: str): self.secondary_status = "done" def load_database(self, database_name: str): + from bw2data.backends import sqlite3_lci_db + # start loading thread for secondary metadata thread = SecondaryLoadThread( databases=[database_name], @@ -91,6 +95,8 @@ def load_database(self, database_name: str): self.primary_load_database(database_name) def primary_load_database(self, database_name: str): + from bw2data.backends import sqlite3_lci_db + with sqlite3.connect(sqlite3_lci_db._filepath) as con: fields = ', '.join(primary[1:]) # Exclude 'key' as it's constructed primary_df = pd.read_sql(f"SELECT {fields} FROM activitydataset WHERE database = '{database_name}'", con) @@ -105,6 +111,8 @@ def primary_load_database(self, database_name: str): self.mds.register_mutation(idx, "add") def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): + from bw2data.backends import sqlite3_lci_db + if secondary_df.empty or sqlite_db != str(sqlite3_lci_db._filepath): return From 15c36a9c3d68263c566efa1bdf63d795cb1d03b2 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 6 Nov 2025 15:48:15 +0100 Subject: [PATCH 094/267] Refactor import statements to use commontasks module for improved organization and clarity --- .../actions/activity/activity_modify.py | 4 ++-- .../actions/activity/activity_new_process.py | 5 +++-- .../actions/activity/activity_new_product.py | 5 +++-- .../actions/activity/activity_open.py | 9 +++++---- .../activity/activity_sdf_to_clipboard.py | 6 +++--- .../activity/process_property_modify.py | 7 ++++--- .../activity/process_property_remove.py | 4 ++-- .../cs_add_functional_unit.py | 4 ++-- .../actions/calculation_setup/cs_calculate.py | 19 +++++++++++-------- .../cs_change_functional_unit.py | 1 - .../exchange/exchange_sdf_to_clipboard.py | 6 +++--- .../activity_details/activity_details.py | 5 +++-- .../pages/activity_details/activity_header.py | 9 +++++---- .../pages/activity_details/consumers_tab.py | 7 ++++--- .../pages/activity_details/description_tab.py | 9 +++++---- .../pages/activity_details/exchanges_tab.py | 10 +++++----- .../app/pages/activity_details/graph_tab.py | 9 +++++---- .../app/pages/lca_results/LCA_results.py | 12 +++++++----- .../app/pages/parameters/parameter_models.py | 5 +++-- activity_browser/app/panes/databases.py | 5 +++-- 20 files changed, 78 insertions(+), 63 deletions(-) diff --git a/activity_browser/actions/activity/activity_modify.py b/activity_browser/actions/activity/activity_modify.py index 07abb772b..59ecf13fe 100644 --- a/activity_browser/actions/activity/activity_modify.py +++ b/activity_browser/actions/activity/activity_modify.py @@ -1,7 +1,7 @@ from activity_browser.actions.base import ABAction, exception_dialogs from bw2data import get_node, Node from activity_browser.ui.icons import qicons -from activity_browser import bwutils +from activity_browser.bwutils.commontasks import refresh_node class ActivityModify(ABAction): @@ -17,7 +17,7 @@ class ActivityModify(ABAction): @staticmethod @exception_dialogs def run(activity: tuple | int | Node, field: str, value: any): - activity = bwutils.refresh_node(activity) + activity = refresh_node(activity) if field == "product": # for some reason product needs to be set like this diff --git a/activity_browser/actions/activity/activity_new_process.py b/activity_browser/actions/activity/activity_new_process.py index b37b36c47..deb42dea2 100644 --- a/activity_browser/actions/activity/activity_new_process.py +++ b/activity_browser/actions/activity/activity_new_process.py @@ -3,7 +3,8 @@ from qtpy.QtWidgets import QDialog import bw2data as bd -from activity_browser import app, bwutils +from activity_browser import app +from activity_browser.bwutils.commontasks import database_is_legacy from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.dialogs.new_node_dialog import NewNodeDialog @@ -36,7 +37,7 @@ def run(database_name: str): ref_product = name database = bd.Database(database_name) - legacy_backend = bwutils.database_is_legacy(database_name) + legacy_backend = database_is_legacy(database_name) # create process new_proc_data = { diff --git a/activity_browser/actions/activity/activity_new_product.py b/activity_browser/actions/activity/activity_new_product.py index fb5149ccd..8bee870c5 100644 --- a/activity_browser/actions/activity/activity_new_product.py +++ b/activity_browser/actions/activity/activity_new_product.py @@ -6,7 +6,8 @@ from bw_functional import Process -from activity_browser import app, bwutils +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -44,7 +45,7 @@ def run(activities: list[tuple | int | bd.Node], product_type: str = "product"): Raises: AssertionError: If an activity is not of type `Process`. """ - activities = [bwutils.refresh_node(activity) for activity in activities] + activities = [refresh_node(activity) for activity in activities] for act in activities: assert isinstance(act, Process), "Cannot create new product for non-process type" diff --git a/activity_browser/actions/activity/activity_open.py b/activity_browser/actions/activity/activity_open.py index 422322922..e09700a37 100644 --- a/activity_browser/actions/activity/activity_open.py +++ b/activity_browser/actions/activity/activity_open.py @@ -3,7 +3,8 @@ import bw2data as bd import bw_functional as bf -from activity_browser import bwutils, app +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node, is_node_process from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -44,13 +45,13 @@ def run(activities: list[tuple | int | bd.Node]): from activity_browser.app import pages # Refresh the activity nodes to ensure they are up-to-date - activities = [bwutils.refresh_node(activity) for activity in activities] - processes = [bwutils.refresh_node(function["processor"]) for function in activities if isinstance(function, bf.Product)] + activities = [refresh_node(activity) for activity in activities] + processes = [refresh_node(function["processor"]) for function in activities if isinstance(function, bf.Product)] activities = list(set(activities + processes)) for act in activities: # Check if the activity type is supported - if not bwutils.is_node_process(act): + if not is_node_process(act): logger.warning(f"Can't open activity {act.key} - opening type: `{act.get('type')}` not supported") continue diff --git a/activity_browser/actions/activity/activity_sdf_to_clipboard.py b/activity_browser/actions/activity/activity_sdf_to_clipboard.py index e35568cf5..93fbbe0f0 100644 --- a/activity_browser/actions/activity/activity_sdf_to_clipboard.py +++ b/activity_browser/actions/activity/activity_sdf_to_clipboard.py @@ -3,7 +3,7 @@ import bw2data as bd import bw_functional as bf -from activity_browser import bwutils +from activity_browser.bwutils.commontasks import refresh_node, exchanges_to_sdf from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -21,7 +21,7 @@ class ActivitySDFToClipboard(ABAction): @staticmethod @exception_dialogs def run(activities: List[tuple | int | bd.Node]): - activities = [bwutils.refresh_node(node) for node in activities] + activities = [refresh_node(node) for node in activities] exchanges = [] for activity in activities: @@ -33,5 +33,5 @@ def run(activities: List[tuple | int | bd.Node]): else: exchanges += [exc.as_dict() for exc in activity.exchanges()] - df = bwutils.exchanges_to_sdf(exchanges) + df = exchanges_to_sdf(exchanges) df.to_clipboard(excel=True, index=False) diff --git a/activity_browser/actions/activity/process_property_modify.py b/activity_browser/actions/activity/process_property_modify.py index 1d902438d..cfb30e0f0 100644 --- a/activity_browser/actions/activity/process_property_modify.py +++ b/activity_browser/actions/activity/process_property_modify.py @@ -1,6 +1,7 @@ from qtpy import QtWidgets, QtCore -from activity_browser import app, bwutils +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -18,7 +19,7 @@ class ProcessPropertyModify(ABAction): Args: process (tuple | int | Process): The process to modify. Can be a tuple, integer, or Process object. - property_name (str, optional): The name of the property to modify. Defaults to None. + property_name (str, optional): The name of the property to modify. Defaults to None. Raises: ValueError: If the provided process is not of type Process. @@ -33,7 +34,7 @@ def run(process: tuple | int | Process, property_name: str = None ): - process = bwutils.refresh_node(process) + process = refresh_node(process) if not isinstance(process, Process): raise ValueError(f"Expected a Process-type activity, got {type(process)} instead") diff --git a/activity_browser/actions/activity/process_property_remove.py b/activity_browser/actions/activity/process_property_remove.py index 29ec91d06..b7677caea 100644 --- a/activity_browser/actions/activity/process_property_remove.py +++ b/activity_browser/actions/activity/process_property_remove.py @@ -1,6 +1,6 @@ from loguru import logger -from activity_browser import bwutils +from activity_browser.bwutils.commontasks import refresh_node from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -36,7 +36,7 @@ class ProcessPropertyRemove(ABAction): @staticmethod @exception_dialogs def run(process: tuple | int | Process, property_name: str): - process = bwutils.refresh_node(process) + process = refresh_node(process) if not isinstance(process, Process): raise ValueError(f"Expected a Process-type activity, got {type(process)} instead") diff --git a/activity_browser/actions/calculation_setup/cs_add_functional_unit.py b/activity_browser/actions/calculation_setup/cs_add_functional_unit.py index d196cdcd9..0d026fd06 100644 --- a/activity_browser/actions/calculation_setup/cs_add_functional_unit.py +++ b/activity_browser/actions/calculation_setup/cs_add_functional_unit.py @@ -1,6 +1,6 @@ from loguru import logger -from activity_browser import bwutils +from activity_browser.bwutils.commontasks import refresh_node from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd @@ -13,7 +13,7 @@ class CSAddFunctionalUnit(ABAction): @staticmethod @exception_dialogs def run(cs_name: str, activities: list[tuple | int | bd.Node]): - activities = [bwutils.refresh_node(node) for node in activities] + activities = [refresh_node(node) for node in activities] calculation_setup = bd.calculation_setups[cs_name] fus = [{act.key: -1.0 if act.get("type") == "waste" else 1.0} for act in activities] diff --git a/activity_browser/actions/calculation_setup/cs_calculate.py b/activity_browser/actions/calculation_setup/cs_calculate.py index 3c01f73b8..e8fe423a0 100644 --- a/activity_browser/actions/calculation_setup/cs_calculate.py +++ b/activity_browser/actions/calculation_setup/cs_calculate.py @@ -5,7 +5,10 @@ from qtpy import QtCore, QtWidgets -from activity_browser import app, bwutils +from activity_browser import app +from activity_browser.bwutils.multilca import MLCA, Contributions +from activity_browser.bwutils.superstructure import SuperstructureMLCA, SuperstructureContributions +from activity_browser.bwutils.montecarlo import MonteCarloLCA from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -35,20 +38,20 @@ def run(cs_name: str, scenario_data: pd.DataFrame = None): if not cs.get("ia"): raise Exception(f"Calculation setup '{cs_name}' has no impact assessment methods.") - dialog = CalculationDialog(cs_name, application.main_window) + dialog = CalculationDialog(cs_name, app.main_window) dialog.show() - application.thread().eventDispatcher().processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + app.application.thread().eventDispatcher().processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) try: if scenario_data is None: - mlca = bwutils.MLCA(cs_name) - contributions = bwutils.Contributions(mlca) + mlca = MLCA(cs_name) + contributions = Contributions(mlca) else: - mlca = bwutils.SuperstructureMLCA(cs_name, scenario_data) - contributions = bwutils.SuperstructureContributions(mlca) + mlca = SuperstructureMLCA(cs_name, scenario_data) + contributions = SuperstructureContributions(mlca) mlca.calculate() - mc = bwutils.MonteCarloLCA(cs_name) + mc = MonteCarloLCA(cs_name) page = pages.LCAResultsPage(cs_name, mlca, contributions, mc) central = app.main_window.centralWidget() diff --git a/activity_browser/actions/calculation_setup/cs_change_functional_unit.py b/activity_browser/actions/calculation_setup/cs_change_functional_unit.py index 947c87043..be08809c6 100644 --- a/activity_browser/actions/calculation_setup/cs_change_functional_unit.py +++ b/activity_browser/actions/calculation_setup/cs_change_functional_unit.py @@ -1,6 +1,5 @@ from loguru import logger -from activity_browser import bwutils from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd diff --git a/activity_browser/actions/exchange/exchange_sdf_to_clipboard.py b/activity_browser/actions/exchange/exchange_sdf_to_clipboard.py index cf7c4d9cf..5c225dbd5 100644 --- a/activity_browser/actions/exchange/exchange_sdf_to_clipboard.py +++ b/activity_browser/actions/exchange/exchange_sdf_to_clipboard.py @@ -3,7 +3,7 @@ import bw2data as bd import bw_functional as bf -from activity_browser import bwutils +from activity_browser.bwutils.commontasks import refresh_edge, exchanges_to_sdf from activity_browser.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -21,7 +21,7 @@ class ExchangeSDFToClipboard(ABAction): @staticmethod @exception_dialogs def run(exchanges: List[int | bd.Edge]): - exchanges = [bwutils.refresh_edge(edge) for edge in exchanges] + exchanges = [refresh_edge(edge) for edge in exchanges] virtual_exchanges = [] for exchange in exchanges: @@ -30,5 +30,5 @@ def run(exchanges: List[int | bd.Edge]): else: virtual_exchanges.append(exchange.as_dict()) - df = bwutils.exchanges_to_sdf(virtual_exchanges) + df = exchanges_to_sdf(virtual_exchanges) df.to_clipboard(excel=True, index=False) diff --git a/activity_browser/app/pages/activity_details/activity_details.py b/activity_browser/app/pages/activity_details/activity_details.py index 6c7aec4b3..f18c0126c 100644 --- a/activity_browser/app/pages/activity_details/activity_details.py +++ b/activity_browser/app/pages/activity_details/activity_details.py @@ -4,7 +4,8 @@ import bw2data as bd -from activity_browser import app, bwutils +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node_or_none from activity_browser.ui import widgets from .activity_header import ActivityHeader @@ -149,7 +150,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - self.activity = bwutils.refresh_node_or_none(self.activity) + self.activity = refresh_node_or_none(self.activity) if self.activity is None: # Activity was already deleted diff --git a/activity_browser/app/pages/activity_details/activity_header.py b/activity_browser/app/pages/activity_details/activity_header.py index 6c73df1e8..ea6070fdc 100644 --- a/activity_browser/app/pages/activity_details/activity_header.py +++ b/activity_browser/app/pages/activity_details/activity_header.py @@ -3,7 +3,8 @@ import bw2data as bd import bw_functional as bf -from activity_browser import app, actions, bwutils +from activity_browser import app, actions +from activity_browser.bwutils.commontasks import refresh_node, database_is_locked from activity_browser.ui import widgets @@ -37,11 +38,11 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - self.activity = bwutils.refresh_node(self.activity) + self.activity = refresh_node(self.activity) self.clear_layout() - if bwutils.database_is_locked(self.activity["database"]): + if database_is_locked(self.activity["database"]): self.layout().addWidget(LockedWarningBar(self)) self.layout().addLayout(self.build_grid()) @@ -66,7 +67,7 @@ def build_grid(self) -> QtWidgets.QGridLayout: grid.setSpacing(10) grid.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) - db_locked = bwutils.database_is_locked(self.activity["database"]) + db_locked = database_is_locked(self.activity["database"]) setup = self.disabled_setup() if db_locked else self.enabled_setup() # Arrange widgets for display as a grid diff --git a/activity_browser/app/pages/activity_details/consumers_tab.py b/activity_browser/app/pages/activity_details/consumers_tab.py index 7dc5cf721..73c959369 100644 --- a/activity_browser/app/pages/activity_details/consumers_tab.py +++ b/activity_browser/app/pages/activity_details/consumers_tab.py @@ -4,7 +4,8 @@ import bw2data as bd import bw_functional as bf -from activity_browser import actions, bwutils, app +from activity_browser import actions, app +from activity_browser.bwutils.commontasks import refresh_node from activity_browser.ui import widgets, icons @@ -27,7 +28,7 @@ def __init__(self, activity: tuple | int | bd.Node, parent=None): """ super().__init__(parent) - self.activity = bwutils.refresh_node(activity) + self.activity = refresh_node(activity) self.view = ConsumersView(self) self.model = ConsumersModel(self) @@ -50,7 +51,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - self.activity = bwutils.refresh_node(self.activity) + self.activity = refresh_node(self.activity) exchanges = [] if isinstance(self.activity, bf.Process): for product in self.activity.products(): diff --git a/activity_browser/app/pages/activity_details/description_tab.py b/activity_browser/app/pages/activity_details/description_tab.py index 266247726..03e8b84cd 100644 --- a/activity_browser/app/pages/activity_details/description_tab.py +++ b/activity_browser/app/pages/activity_details/description_tab.py @@ -2,7 +2,8 @@ import bw2data as bd -from activity_browser import bwutils, actions +from activity_browser import actions +from activity_browser.bwutils.commontasks import refresh_node, database_is_locked class DescriptionTab(QtWidgets.QTextEdit): @@ -20,7 +21,7 @@ def __init__(self, activity: tuple | int | bd.Node, parent=None): activity (tuple | int | bd.Node): The activity to display and edit the description for. parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. """ - self.activity = bwutils.refresh_node(activity) + self.activity = refresh_node(activity) super().__init__(parent, self.activity.get("comment", "")) self.setPlaceholderText("Click here to edit the description of this activity...") @@ -28,12 +29,12 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - self.activity = bwutils.refresh_node(self.activity) + self.activity = refresh_node(self.activity) self.setText(self.activity.get("comment", "")) self.moveCursor(QtGui.QTextCursor.MoveOperation.End) # Set the read-only state based on the activity's database - self.setReadOnly(bwutils.database_is_locked(self.activity["database"])) + self.setReadOnly(database_is_locked(self.activity["database"])) def focusOutEvent(self, e): """ diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index 8ec62e968..d5492ea2c 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -8,8 +8,8 @@ import bw_functional as bf -from activity_browser import actions, bwutils, app -from activity_browser.bwutils.commontasks import refresh_node, database_is_locked, database_is_legacy +from activity_browser import actions, app +from activity_browser.bwutils.commontasks import refresh_node, database_is_locked, database_is_legacy, is_node_product, is_node_biosphere, parameters_in_scope from activity_browser.ui import widgets, icons, delegates @@ -232,9 +232,9 @@ def dropEvent(self, event): actions.ExchangeNew.run(keys, self.activity.key, exc_type) def get_exchange_type(activity_key: tuple) -> str | None: - if bwutils.is_node_product(activity_key): + if is_node_product(activity_key): return "technosphere" - elif bwutils.is_node_biosphere(activity_key): + elif is_node_biosphere(activity_key): return "biosphere" return None @@ -506,7 +506,7 @@ def scoped_parameters(self): Returns: dict: The parameters in scope. """ - return bwutils.parameters_in_scope(self["_exchange"].output) + return parameters_in_scope(self["_exchange"].output) def flags(self, col: int, key: str): """ diff --git a/activity_browser/app/pages/activity_details/graph_tab.py b/activity_browser/app/pages/activity_details/graph_tab.py index 08773f649..41ded8290 100644 --- a/activity_browser/app/pages/activity_details/graph_tab.py +++ b/activity_browser/app/pages/activity_details/graph_tab.py @@ -8,7 +8,8 @@ import bw2data as bd import bw_functional as bf -from activity_browser import static, bwutils, actions +from activity_browser import static, actions +from activity_browser.bwutils.commontasks import refresh_node, database_is_locked from activity_browser.ui import widgets from .exchanges_tab import get_exchange_type @@ -40,7 +41,7 @@ def __init__(self, activity, parent=None): super().__init__(parent) self.setAcceptDrops(True) - self.activity = bwutils.refresh_node(activity) + self.activity = refresh_node(activity) self.expanded_nodes = {self.activity.id} self.button = QtWidgets.QPushButton("CLICK ME") @@ -70,7 +71,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - self.activity = bwutils.refresh_node(self.activity) + self.activity = refresh_node(self.activity) json = self.build_json() self.bridge.update_graph.emit(json) @@ -205,7 +206,7 @@ def dragEnterEvent(self, event): Args: event: The drag enter event. """ - if bwutils.database_is_locked(self.parent().activity["database"]): + if database_is_locked(self.parent().activity["database"]): return if event.mimeData().hasFormat("application/bw-nodekeylist"): diff --git a/activity_browser/app/pages/lca_results/LCA_results.py b/activity_browser/app/pages/lca_results/LCA_results.py index 3fd761e23..fb8666cdf 100644 --- a/activity_browser/app/pages/lca_results/LCA_results.py +++ b/activity_browser/app/pages/lca_results/LCA_results.py @@ -11,7 +11,9 @@ from stats_arrays.errors import InvalidParamsError -from activity_browser import app, bwutils, settings +from activity_browser import app, settings +from activity_browser.bwutils.commontasks import unit_of_method, get_LCIA_method_name_dict, format_activity_label +from activity_browser.bwutils.sensitivity_analysis import GlobalSensitivityAnalysis from activity_browser.mod.bw2analyzer import ABContributionAnalysis from activity_browser.ui import icons, web, widgets @@ -56,7 +58,7 @@ def get_unit(method: tuple, relative: bool = False) -> str: if relative: return "relative share" if method: # for all reference flows - return bwutils.commontasks.unit_of_method(method) + return unit_of_method(method) return "units of each impact category" @@ -102,7 +104,7 @@ def __init__(self, cs_name, mlca, contributions, mc, parent=None): self.cs_name, self.mlca, self.contributions, self.mc = cs_name, mlca, contributions, mc self.cs = bd.calculation_setups[self.cs_name] self.has_scenarios: bool = hasattr(mlca, "scenario_names") - self.method_dict = bwutils.commontasks.get_LCIA_method_name_dict(self.mlca.methods) + self.method_dict = get_LCIA_method_name_dict(self.mlca.methods) self.single_func_unit = len(self.mlca.func_units) == 1 self.single_method = len(self.mlca.methods) == 1 @@ -816,7 +818,7 @@ def update_plot(self, method_index: int = 0): method = self.parent.mlca.methods[method_index] df = self.parent.mlca.get_results_for_method(method_index) labels = [ - bwutils.commontasks.format_activity_label(next(iter(fu.keys())), style="pnld") + format_activity_label(next(iter(fu.keys())), style="pnld") for fu in self.parent.mlca.func_units ] idx = self.layout.indexOf(self.plot) @@ -2008,7 +2010,7 @@ def __init__(self, parent=None): super(GSATab, self).__init__(parent) self.parent = parent - self.GSA = bwutils.GlobalSensitivityAnalysis(self.parent.mc) + self.GSA = GlobalSensitivityAnalysis(self.parent.mc) header_ = QtWidgets.QToolBar() _header = header("Global Sensitivity Analysis") diff --git a/activity_browser/app/pages/parameters/parameter_models.py b/activity_browser/app/pages/parameters/parameter_models.py index 8540865b7..d6fad62c6 100644 --- a/activity_browser/app/pages/parameters/parameter_models.py +++ b/activity_browser/app/pages/parameters/parameter_models.py @@ -12,7 +12,8 @@ from bw2data.parameters import ActivityParameter, DatabaseParameter, Group, ProjectParameter -from activity_browser import actions, signals, application, bwutils +from activity_browser import actions, signals, application +from activity_browser.bwutils.utils import Parameters from activity_browser.mod import bw2data as bd from activity_browser.ui.dialogs import UncertaintyWizard @@ -435,7 +436,7 @@ def sync(self, df: pd.DataFrame = None, include_default: bool = True) -> None: """Construct the dataframe from the existing parameters, if ``df`` is given, perform a merge to possibly include additional columns. """ - data = [p[:3] for p in bwutils.utils.Parameters.from_bw_parameters()] + data = [p[:3] for p in Parameters.from_bw_parameters()] if not isinstance(df, pd.DataFrame): self._dataframe = pd.DataFrame(data, columns=self.HEADERS).set_index("Name") else: diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py index 515c03eb5..7cef124dd 100644 --- a/activity_browser/app/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -6,7 +6,8 @@ import bw2data as bd import pandas as pd -from activity_browser import app, actions, bwutils +from activity_browser import app, actions +from activity_browser.bwutils.commontasks import count_database_records from activity_browser.ui import widgets, icons, delegates, core from activity_browser.app.menu_bar import ImportDatabaseMenu @@ -87,7 +88,7 @@ def build_df(self) -> pd.DataFrame: "name": name, "depends": ", ".join(bd.databases[name].get("depends", [])), "modified": dt, - "records": bwutils.commontasks.count_database_records(name), + "records": count_database_records(name), "read_only": bd.databases[name].get("read_only", True), "default_allocation": bd.databases[name].get("default_allocation", "unspecified"), "backend": bd.databases[name].get("backend") From 8d6bfb726138cfc0b051f8f6786f8720e75ca262 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 6 Nov 2025 17:32:34 +0100 Subject: [PATCH 095/267] Refactor application imports and enhance singleton pattern in MainWindow and MetaDataStore classes --- activity_browser/app/main_window.py | 13 +++++++ .../pages/activity_details/exchanges_tab.py | 2 +- activity_browser/bwutils/commontasks.py | 2 +- activity_browser/bwutils/metadata/metadata.py | 5 +++ activity_browser/bwutils/metadata/updater.py | 3 ++ activity_browser/ui/icons.py | 35 ++++++------------- activity_browser/ui/widgets/plot.py | 5 +-- activity_browser/ui/widgets/wizard_page.py | 2 +- tests/actions/test_activity_actions.py | 4 +-- .../actions/test_calculation_setup_actions.py | 2 -- tests/actions/test_database_actions.py | 8 ++--- tests/actions/test_exchange_actions.py | 2 +- tests/actions/test_method_actions.py | 2 -- tests/conftest.py | 16 +++++---- 14 files changed, 54 insertions(+), 47 deletions(-) diff --git a/activity_browser/app/main_window.py b/activity_browser/app/main_window.py index 1a82067eb..fcc64494b 100644 --- a/activity_browser/app/main_window.py +++ b/activity_browser/app/main_window.py @@ -5,9 +5,20 @@ class MainWindow(QtWidgets.QMainWindow): + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + def __init__(self, parent=None): from activity_browser.app.menu_bar import MenuBar + + if self._initialized: + return super().__init__(parent) @@ -21,6 +32,8 @@ def __init__(self, parent=None): self.connect_signals() + self._initialized = True + def sync(self): """ Synchronizes the main window layout with the current Brightway2 project. diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index d5492ea2c..37b51095d 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -644,7 +644,7 @@ def backgroundData(self, col: int, key: str): return QtGui.QBrush(QtGui.QColor(self.background_color)) if key == f"property_{self['_allocate_by']}": - from activity_browser import application + from activity_browser.app import application return application.palette().alternateBase() def setData(self, col: int, key: str, value) -> bool: diff --git a/activity_browser/bwutils/commontasks.py b/activity_browser/bwutils/commontasks.py index 38058a96c..c24bd0004 100644 --- a/activity_browser/bwutils/commontasks.py +++ b/activity_browser/bwutils/commontasks.py @@ -392,7 +392,7 @@ def generate_copy_code(key: tuple) -> str: .apply(lambda x: x[1] if code in x[1] and "_copy" in x[1] else None) .dropna() .to_list() - if not metadata.empty + if not meta.empty else [] ) if not copies: diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 8810b9d60..d88e43ebf 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -20,6 +20,9 @@ def __init__(self): from .loader import MDSLoader from .updater import MDSUpdater + if self._initialized: + return + self._dataframe = pd.DataFrame() self._added: set[tuple[str, str]] = set() @@ -29,6 +32,8 @@ def __init__(self): self.loader = MDSLoader(self) self.updater = MDSUpdater(self) + self._initialized = True + @property def dataframe(self) -> pd.DataFrame: return self._dataframe diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index c7a021c58..907e25579 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -95,6 +95,9 @@ def add_database(self, db_name: str): self.mds.loader.load_database(db_name) def delete_database(self, db_name: str): + if db_name not in self.mds.databases: + return + for code in self.mds.dataframe.loc[db_name].index: self.mds.register_mutation((db_name, code), "delete") diff --git a/activity_browser/ui/icons.py b/activity_browser/ui/icons.py index d656fc779..00a759275 100644 --- a/activity_browser/ui/icons.py +++ b/activity_browser/ui/icons.py @@ -13,33 +13,11 @@ def create_path(folder: str, filename: str) -> str: def empty_icon(size: QSize = QSize(32, 32)) -> QIcon: - print("This it?") pixmap = QPixmap(size) pixmap.fill(Qt.transparent) # Make the pixmap transparent return QIcon(pixmap) -# CURRENTLY UNUSED ICONS - -# Modular LCA (keep until this is reintegrated) -# add_db = create_path('metaprocess', 'add_database.png') -# close_db = create_path('metaprocess', 'close_database.png') -# cut = create_path('metaprocess', 'cut.png') -# debug = create_path('main', 'ladybird.png') -# duplicate = create_path('metaprocess', 'duplicate.png') -# graph_lmp = create_path('metaprocess', 'graph_linkedmetaprocess.png') -# graph_mp = create_path('metaprocess', 'graph_metaprocess.png') -# load_db = create_path('metaprocess', 'open_database.png') -# metaprocess = create_path('metaprocess', 'metaprocess.png') -# new = create_path('metaprocess', 'new_metaprocess.png') -# save_db = create_path('metaprocess', 'save_database.png') -# save_mp = create_path('metaprocess', 'save_metaprocess.png') - -# key = create_path('main', 'key.png') -# search = create_path('main', 'search.png') -# switch = create_path('main', 'switch-state.png') - - icons = dict( # Icons from href="https://www.flaticon.com/ @@ -102,6 +80,15 @@ def empty_icon(size: QSize = QSize(32, 32)) -> QIcon: ) -qicons = type("QIcons", (object,), {k: QIcon(v) for k, v in icons.items()}) -qicons.empty = empty_icon() +class QIcons: + """Lazily loads QIcon instances only when accessed.""" + def __getattribute__(self, name): + if name == 'empty': + return empty_icon() + elif name in icons: + return QIcon(icons[name]) + else: + raise AttributeError(f"QIcons has no icon '{name}'") + +qicons = QIcons() diff --git a/activity_browser/ui/widgets/plot.py b/activity_browser/ui/widgets/plot.py index ecef4dbf1..998c342b4 100644 --- a/activity_browser/ui/widgets/plot.py +++ b/activity_browser/ui/widgets/plot.py @@ -1,7 +1,8 @@ -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg -from matplotlib.figure import Figure + from qtpy import QtWidgets +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg +from matplotlib.figure import Figure class ABPlot(QtWidgets.QWidget): ALL_FILTER = "All Files (*.*)" diff --git a/activity_browser/ui/widgets/wizard_page.py b/activity_browser/ui/widgets/wizard_page.py index 122ebd2c8..295d6288f 100644 --- a/activity_browser/ui/widgets/wizard_page.py +++ b/activity_browser/ui/widgets/wizard_page.py @@ -41,7 +41,7 @@ class ABThreadedWizardPage(ABWizardPage): Thread: type["ABThread"] def __init__(self, parent=None): - from activity_browser import application + from activity_browser.app import application super().__init__(parent) diff --git a/tests/actions/test_activity_actions.py b/tests/actions/test_activity_actions.py index b68dafde2..afcba0607 100644 --- a/tests/actions/test_activity_actions.py +++ b/tests/actions/test_activity_actions.py @@ -3,7 +3,7 @@ from bw2data.errors import BW2Exception from qtpy import QtWidgets -from activity_browser import actions, application +from activity_browser import app, actions def test_activity_delete(monkeypatch, basic_database): @@ -74,7 +74,7 @@ def test_process_open(basic_database): actions.ActivityOpen.run([process.key]) - group = application.main_window.centralWidget().groups["Activity Details"] + group = app.main_window.centralWidget().groups["Activity Details"] assert "activity_details_basic_process" in [group.widget(i).objectName() for i in range(group.count())] diff --git a/tests/actions/test_calculation_setup_actions.py b/tests/actions/test_calculation_setup_actions.py index e8f40a946..0b761a6e2 100644 --- a/tests/actions/test_calculation_setup_actions.py +++ b/tests/actions/test_calculation_setup_actions.py @@ -1,6 +1,4 @@ -import pytest import bw2data as bd -from bw2data.errors import BW2Exception from qtpy import QtWidgets from activity_browser import actions diff --git a/tests/actions/test_database_actions.py b/tests/actions/test_database_actions.py index 5914362ac..a29425787 100644 --- a/tests/actions/test_database_actions.py +++ b/tests/actions/test_database_actions.py @@ -1,7 +1,7 @@ import bw2data as bd from qtpy import QtWidgets -from activity_browser import actions, application +from activity_browser import actions, app def test_database_delete(monkeypatch, basic_database): @@ -31,7 +31,7 @@ def test_database_duplicate(monkeypatch, qtbot, basic_database): actions.DatabaseDuplicate.run(basic_database.name) - dialog = application.main_window.findChild(DuplicateDatabaseDialog) + dialog = app.main_window.findChild(DuplicateDatabaseDialog) with qtbot.waitSignal(dialog.dup_thread.finished, timeout=60 * 1000): pass @@ -55,7 +55,7 @@ def test_database_export_excel(monkeypatch, qtbot, basic_database, tmp_path): actions.DatabaseExportExcel.run([basic_database.name]) # Find the wizard dialog and wait for the export thread to finish - wizard = application.main_window.findChild(ExportExcelSetup) + wizard = app.main_window.findChild(ExportExcelSetup) assert wizard is not None # Wait for the export thread to finish @@ -83,7 +83,7 @@ def test_database_export_bw2package(monkeypatch, qtbot, basic_database, tmp_path actions.DatabaseExportBW2Package.run([basic_database.name]) # Find the wizard dialog and wait for the export thread to finish - wizard = application.main_window.findChild(ExportBW2PackageSetup) + wizard = app.main_window.findChild(ExportBW2PackageSetup) assert wizard is not None # Wait for the export thread to finish diff --git a/tests/actions/test_exchange_actions.py b/tests/actions/test_exchange_actions.py index 1040fa36c..d8511cb72 100644 --- a/tests/actions/test_exchange_actions.py +++ b/tests/actions/test_exchange_actions.py @@ -1,7 +1,7 @@ import pytest from stats_arrays.distributions import NoUncertainty, UndefinedUncertainty, UniformUncertainty -from activity_browser import actions, application +from activity_browser import actions from activity_browser.ui.dialogs import UncertaintyDialog diff --git a/tests/actions/test_method_actions.py b/tests/actions/test_method_actions.py index a7465a55a..22616422e 100644 --- a/tests/actions/test_method_actions.py +++ b/tests/actions/test_method_actions.py @@ -5,11 +5,9 @@ from stats_arrays.distributions import ( NoUncertainty, UndefinedUncertainty, - UniformUncertainty, ) from activity_browser import actions -from activity_browser.ui.dialogs import UncertaintyWizard def test_cf_amount_modify(basic_database): diff --git a/tests/conftest.py b/tests/conftest.py index f712017e6..ee4497673 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,10 +6,6 @@ import bw_functional as bf from bw2data.tests import bw2test -from activity_browser import application -from activity_browser.ui.widgets import CentralTabWidget -from activity_browser.app import pages, MainWindow - @pytest.fixture def no_exception_dialogs(monkeypatch): @@ -24,13 +20,19 @@ def no_exception_dialogs(monkeypatch): @pytest.fixture() def main_window(qtbot, monkeypatch, no_exception_dialogs): """Return the main window of the application instance.""" - main_window = MainWindow() + from activity_browser import app + from activity_browser.app import pages + from activity_browser.ui.widgets import CentralTabWidget + + app.MainWindow._instance = None # Reset singleton instance for testing + main_window = app.MainWindow() central_widget = CentralTabWidget(main_window) qtbot.addWidget(main_window) - setattr(application, "main_window", main_window) + setattr(app.application, "main_window", main_window) + setattr(app, "main_window", main_window) - central_widget.addTab(pages.WelcomePage(), "Welcome") + # central_widget.addTab(pages.WelcomePage(), "Welcome") central_widget.addTab(pages.ParametersPage(), "Parameters") main_window.setCentralWidget(central_widget) From 6516612d8c804c03c34e6bdb515508c838590663 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 6 Nov 2025 18:23:38 +0100 Subject: [PATCH 096/267] Refactored actions module into app module --- activity_browser/__main__.py | 4 +- activity_browser/app/__init__.py | 13 ++++-- .../{ => app}/actions/__init__.py | 0 .../actions/activity/activity_delete.py | 2 +- .../actions/activity/activity_duplicate.py | 2 +- .../activity/activity_duplicate_to_db.py | 2 +- .../actions/activity/activity_modify.py | 2 +- .../actions/activity/activity_new_process.py | 2 +- .../actions/activity/activity_new_product.py | 2 +- .../actions/activity/activity_open.py | 2 +- .../actions/activity/activity_relink.py | 10 ++--- .../activity/activity_sdf_to_clipboard.py | 2 +- .../activity/process_property_modify.py | 2 +- .../activity/process_property_remove.py | 2 +- activity_browser/{ => app}/actions/base.py | 0 .../cs_add_functional_unit.py | 2 +- .../cs_add_impact_category.py | 2 +- .../actions/calculation_setup/cs_calculate.py | 2 +- .../cs_change_functional_unit.py | 2 +- .../actions/calculation_setup/cs_delete.py | 2 +- .../cs_delete_functional_unit.py | 2 +- .../cs_delete_impact_category.py | 2 +- .../actions/calculation_setup/cs_duplicate.py | 2 +- .../actions/calculation_setup/cs_new.py | 6 +-- .../actions/calculation_setup/cs_open.py | 2 +- .../actions/calculation_setup/cs_rename.py | 2 +- .../actions/database/database_delete.py | 2 +- .../actions/database/database_duplicate.py | 2 +- .../database/database_explorer_open.py | 2 +- .../database/database_export_bw2package.py | 2 +- .../actions/database/database_export_excel.py | 2 +- .../database_import_from_ecoinvent.py | 2 +- .../database/database_importer_bw2package.py | 2 +- .../database/database_importer_excel.py | 2 +- .../actions/database/database_new.py | 4 +- .../actions/database/database_open.py | 2 +- .../actions/database/database_process.py | 2 +- .../actions/database/database_relink.py | 6 +-- .../actions/database/database_set_readonly.py | 2 +- .../actions/exchange/exchange_copy_sdf.py | 2 +- .../actions/exchange/exchange_delete.py | 2 +- .../exchange/exchange_formula_remove.py | 2 +- .../actions/exchange/exchange_modify.py | 2 +- .../actions/exchange/exchange_new.py | 2 +- .../exchange/exchange_sdf_to_clipboard.py | 2 +- .../exchange/exchange_uncertainty_modify.py | 2 +- .../exchange/exchange_uncertainty_remove.py | 2 +- .../{ => app}/actions/metadatastore_open.py | 2 +- .../actions/method/cf_amount_modify.py | 2 +- .../{ => app}/actions/method/cf_new.py | 2 +- .../{ => app}/actions/method/cf_remove.py | 2 +- .../actions/method/cf_uncertainty_modify.py | 2 +- .../actions/method/cf_uncertainty_remove.py | 2 +- .../method/importer/method_importer_bw2io.py | 2 +- .../importer/method_importer_ecoinvent.py | 2 +- .../{ => app}/actions/method/method_delete.py | 2 +- .../actions/method/method_duplicate.py | 2 +- .../actions/method/method_meta_modify.py | 2 +- .../{ => app}/actions/method/method_new.py | 2 +- .../{ => app}/actions/method/method_open.py | 2 +- .../{ => app}/actions/method/method_rename.py | 2 +- .../{ => app}/actions/migrations_install.py | 2 +- .../parameter/parameter_clear_broken.py | 2 +- .../actions/parameter/parameter_delete.py | 2 +- .../actions/parameter/parameter_modify.py | 2 +- .../actions/parameter/parameter_new.py | 6 +-- .../parameter/parameter_new_automatic.py | 2 +- .../parameter/parameter_new_from_parameter.py | 2 +- .../actions/parameter/parameter_rename.py | 2 +- .../parameter/parameter_uncertainty_modify.py | 2 +- .../parameter/parameter_uncertainty_remove.py | 2 +- .../project/project_create_template.py | 2 +- .../actions/project/project_delete.py | 2 +- .../actions/project/project_duplicate.py | 2 +- .../actions/project/project_export.py | 2 +- .../actions/project/project_import.py | 2 +- .../actions/project/project_local_import.py | 2 +- .../actions/project/project_manager_open.py | 2 +- .../actions/project/project_migrate25.py | 2 +- .../{ => app}/actions/project/project_new.py | 2 +- .../actions/project/project_new_remote.py | 2 +- .../actions/project/project_new_template.py | 2 +- .../actions/project/project_remote_import.py | 2 +- .../actions/project/project_switch.py | 2 +- .../{ => app}/actions/pyside_upgrade.py | 2 +- .../{ => app}/actions/settings_wizard_open.py | 2 +- .../tools/bw2io/tools_bw2io_migrations.py | 2 +- activity_browser/app/menu_bar.py | 42 +++++++++---------- .../pages/activity_details/activity_header.py | 16 +++---- .../pages/activity_details/consumers_tab.py | 4 +- .../app/pages/activity_details/data_tab.py | 4 +- .../pages/activity_details/description_tab.py | 4 +- .../pages/activity_details/exchanges_tab.py | 26 ++++++------ .../app/pages/activity_details/graph_tab.py | 4 +- .../pages/activity_details/parameters_tab.py | 8 ++-- .../calculation_setup/calculation_setup.py | 6 +-- .../functional_unit_section.py | 12 +++--- .../impact_category_section.py | 6 +-- .../impact_category_details.py | 8 ++-- .../impact_category_header.py | 6 +-- .../app/pages/parameters/parameter_models.py | 14 +++---- .../app/pages/parameters/parameter_views.py | 4 +- .../app/pages/parameters/parameters.py | 6 +-- .../app/pages/parameters/parameters_new.py | 14 +++---- activity_browser/app/pages/welcome.py | 4 +- .../app/panes/calculation_setups.py | 16 +++---- .../app/panes/database_products.py | 20 ++++----- activity_browser/app/panes/databases.py | 22 +++++----- .../app/panes/impact_categories.py | 14 +++---- activity_browser/app/panes/project_manager.py | 10 ++--- .../app/{signals.py => signalling.py} | 0 activity_browser/ui/delegates/formula.py | 4 +- activity_browser/ui/delegates/uncertainty.py | 8 ++-- activity_browser/ui/dialogs/uncertainty.py | 14 +++---- activity_browser/ui/widgets/line_edit.py | 12 +++--- tests/actions/test_activity_actions.py | 16 +++---- .../actions/test_calculation_setup_actions.py | 10 ++--- tests/actions/test_database_actions.py | 30 ++++++------- tests/actions/test_exchange_actions.py | 16 +++---- tests/actions/test_method_actions.py | 26 ++++++------ 120 files changed, 308 insertions(+), 303 deletions(-) rename activity_browser/{ => app}/actions/__init__.py (100%) rename activity_browser/{ => app}/actions/activity/activity_delete.py (97%) rename activity_browser/{ => app}/actions/activity/activity_duplicate.py (90%) rename activity_browser/{ => app}/actions/activity/activity_duplicate_to_db.py (98%) rename activity_browser/{ => app}/actions/activity/activity_modify.py (91%) rename activity_browser/{ => app}/actions/activity/activity_new_process.py (96%) rename activity_browser/{ => app}/actions/activity/activity_new_product.py (98%) rename activity_browser/{ => app}/actions/activity/activity_open.py (96%) rename activity_browser/{ => app}/actions/activity/activity_relink.py (95%) rename activity_browser/{ => app}/actions/activity/activity_sdf_to_clipboard.py (94%) rename activity_browser/{ => app}/actions/activity/process_property_modify.py (98%) rename activity_browser/{ => app}/actions/activity/process_property_remove.py (96%) rename activity_browser/{ => app}/actions/base.py (100%) rename activity_browser/{ => app}/actions/calculation_setup/cs_add_functional_unit.py (90%) rename activity_browser/{ => app}/actions/calculation_setup/cs_add_impact_category.py (85%) rename activity_browser/{ => app}/actions/calculation_setup/cs_calculate.py (97%) rename activity_browser/{ => app}/actions/calculation_setup/cs_change_functional_unit.py (94%) rename activity_browser/{ => app}/actions/calculation_setup/cs_delete.py (95%) rename activity_browser/{ => app}/actions/calculation_setup/cs_delete_functional_unit.py (86%) rename activity_browser/{ => app}/actions/calculation_setup/cs_delete_impact_category.py (86%) rename activity_browser/{ => app}/actions/calculation_setup/cs_duplicate.py (95%) rename activity_browser/{ => app}/actions/calculation_setup/cs_new.py (95%) rename activity_browser/{ => app}/actions/calculation_setup/cs_open.py (89%) rename activity_browser/{ => app}/actions/calculation_setup/cs_rename.py (95%) rename activity_browser/{ => app}/actions/database/database_delete.py (98%) rename activity_browser/{ => app}/actions/database/database_duplicate.py (97%) rename activity_browser/{ => app}/actions/database/database_explorer_open.py (89%) rename activity_browser/{ => app}/actions/database/database_export_bw2package.py (98%) rename activity_browser/{ => app}/actions/database/database_export_excel.py (97%) rename activity_browser/{ => app}/actions/database/database_import_from_ecoinvent.py (99%) rename activity_browser/{ => app}/actions/database/database_importer_bw2package.py (97%) rename activity_browser/{ => app}/actions/database/database_importer_excel.py (98%) rename activity_browser/{ => app}/actions/database/database_new.py (97%) rename activity_browser/{ => app}/actions/database/database_open.py (96%) rename activity_browser/{ => app}/actions/database/database_process.py (83%) rename activity_browser/{ => app}/actions/database/database_relink.py (97%) rename activity_browser/{ => app}/actions/database/database_set_readonly.py (93%) rename activity_browser/{ => app}/actions/exchange/exchange_copy_sdf.py (89%) rename activity_browser/{ => app}/actions/exchange/exchange_delete.py (83%) rename activity_browser/{ => app}/actions/exchange/exchange_formula_remove.py (89%) rename activity_browser/{ => app}/actions/exchange/exchange_modify.py (95%) rename activity_browser/{ => app}/actions/exchange/exchange_new.py (89%) rename activity_browser/{ => app}/actions/exchange/exchange_sdf_to_clipboard.py (93%) rename activity_browser/{ => app}/actions/exchange/exchange_uncertainty_modify.py (91%) rename activity_browser/{ => app}/actions/exchange/exchange_uncertainty_remove.py (88%) rename activity_browser/{ => app}/actions/metadatastore_open.py (88%) rename activity_browser/{ => app}/actions/method/cf_amount_modify.py (92%) rename activity_browser/{ => app}/actions/method/cf_new.py (95%) rename activity_browser/{ => app}/actions/method/cf_remove.py (94%) rename activity_browser/{ => app}/actions/method/cf_uncertainty_modify.py (94%) rename activity_browser/{ => app}/actions/method/cf_uncertainty_remove.py (94%) rename activity_browser/{ => app}/actions/method/importer/method_importer_bw2io.py (96%) rename activity_browser/{ => app}/actions/method/importer/method_importer_ecoinvent.py (98%) rename activity_browser/{ => app}/actions/method/method_delete.py (97%) rename activity_browser/{ => app}/actions/method/method_duplicate.py (98%) rename activity_browser/{ => app}/actions/method/method_meta_modify.py (88%) rename activity_browser/{ => app}/actions/method/method_new.py (97%) rename activity_browser/{ => app}/actions/method/method_open.py (91%) rename activity_browser/{ => app}/actions/method/method_rename.py (98%) rename activity_browser/{ => app}/actions/migrations_install.py (93%) rename activity_browser/{ => app}/actions/parameter/parameter_clear_broken.py (94%) rename activity_browser/{ => app}/actions/parameter/parameter_delete.py (96%) rename activity_browser/{ => app}/actions/parameter/parameter_modify.py (96%) rename activity_browser/{ => app}/actions/parameter/parameter_new.py (97%) rename activity_browser/{ => app}/actions/parameter/parameter_new_automatic.py (95%) rename activity_browser/{ => app}/actions/parameter/parameter_new_from_parameter.py (96%) rename activity_browser/{ => app}/actions/parameter/parameter_rename.py (95%) rename activity_browser/{ => app}/actions/parameter/parameter_uncertainty_modify.py (92%) rename activity_browser/{ => app}/actions/parameter/parameter_uncertainty_remove.py (88%) rename activity_browser/{ => app}/actions/project/project_create_template.py (97%) rename activity_browser/{ => app}/actions/project/project_delete.py (98%) rename activity_browser/{ => app}/actions/project/project_duplicate.py (96%) rename activity_browser/{ => app}/actions/project/project_export.py (97%) rename activity_browser/{ => app}/actions/project/project_import.py (98%) rename activity_browser/{ => app}/actions/project/project_local_import.py (99%) rename activity_browser/{ => app}/actions/project/project_manager_open.py (90%) rename activity_browser/{ => app}/actions/project/project_migrate25.py (98%) rename activity_browser/{ => app}/actions/project/project_new.py (94%) rename activity_browser/{ => app}/actions/project/project_new_remote.py (95%) rename activity_browser/{ => app}/actions/project/project_new_template.py (97%) rename activity_browser/{ => app}/actions/project/project_remote_import.py (99%) rename activity_browser/{ => app}/actions/project/project_switch.py (97%) rename activity_browser/{ => app}/actions/pyside_upgrade.py (97%) rename activity_browser/{ => app}/actions/settings_wizard_open.py (84%) rename activity_browser/{ => app}/actions/tools/bw2io/tools_bw2io_migrations.py (84%) rename activity_browser/app/{signals.py => signalling.py} (100%) diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index be387ee4c..504bb79cc 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -123,7 +123,7 @@ def run(self): class SettingsThread(QtCore.QThread): def run(self): import bw2data as bd - from activity_browser import settings, actions + from activity_browser import settings, app if settings.ab_settings.settings: from pathlib import Path @@ -134,7 +134,7 @@ def run(self): if not bd.projects.twofive: logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") - actions.ProjectSwitch.set_warning_bar() + app.actions.ProjectSwitch.set_warning_bar() logger.info(f"Brightway2 data directory: {bd.projects._base_data_dir}") logger.info(f"Brightway2 current project: {bd.projects.current}") diff --git a/activity_browser/app/__init__.py b/activity_browser/app/__init__.py index aac9ff7eb..b2b4db6d2 100644 --- a/activity_browser/app/__init__.py +++ b/activity_browser/app/__init__.py @@ -1,16 +1,21 @@ # -*- coding: utf-8 -*- -__all__ = ["panes", "pages", "application", "signals", "metadata", "main_window"] +__all__ = ["panes", "pages", "application", "signals", "metadata", "main_window", "actions"] from activity_browser.ui.core.application import ABApplication +from activity_browser.bwutils.metadata import MetaDataStore from .main_window import MainWindow -from .signals import ABSignals application = ABApplication() +metadata = MetaDataStore() + +# modules dependent on application instance +from .signalling import ABSignals signals = ABSignals() +# modules dependent on application and signals +from . import actions + main_window = MainWindow() application.main_window = main_window -from activity_browser.bwutils.metadata import MetaDataStore -metadata = MetaDataStore() diff --git a/activity_browser/actions/__init__.py b/activity_browser/app/actions/__init__.py similarity index 100% rename from activity_browser/actions/__init__.py rename to activity_browser/app/actions/__init__.py diff --git a/activity_browser/actions/activity/activity_delete.py b/activity_browser/app/actions/activity/activity_delete.py similarity index 97% rename from activity_browser/actions/activity/activity_delete.py rename to activity_browser/app/actions/activity/activity_delete.py index 724c6fdfd..213e1186a 100644 --- a/activity_browser/actions/activity/activity_delete.py +++ b/activity_browser/app/actions/activity/activity_delete.py @@ -10,7 +10,7 @@ parameters) from activity_browser.app import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/activity/activity_duplicate.py b/activity_browser/app/actions/activity/activity_duplicate.py similarity index 90% rename from activity_browser/actions/activity/activity_duplicate.py rename to activity_browser/app/actions/activity/activity_duplicate.py index ae19411e6..1197a994d 100644 --- a/activity_browser/actions/activity/activity_duplicate.py +++ b/activity_browser/app/actions/activity/activity_duplicate.py @@ -2,7 +2,7 @@ from qtpy import QtCore -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import commontasks from bw2data import get_activity from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/activity/activity_duplicate_to_db.py b/activity_browser/app/actions/activity/activity_duplicate_to_db.py similarity index 98% rename from activity_browser/actions/activity/activity_duplicate_to_db.py rename to activity_browser/app/actions/activity/activity_duplicate_to_db.py index b1d2549ac..349cff6e1 100644 --- a/activity_browser/actions/activity/activity_duplicate_to_db.py +++ b/activity_browser/app/actions/activity/activity_duplicate_to_db.py @@ -7,7 +7,7 @@ from activity_browser.app import application from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from bw_functional import Product diff --git a/activity_browser/actions/activity/activity_modify.py b/activity_browser/app/actions/activity/activity_modify.py similarity index 91% rename from activity_browser/actions/activity/activity_modify.py rename to activity_browser/app/actions/activity/activity_modify.py index 59ecf13fe..4eb54fa98 100644 --- a/activity_browser/actions/activity/activity_modify.py +++ b/activity_browser/app/actions/activity/activity_modify.py @@ -1,4 +1,4 @@ -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from bw2data import get_node, Node from activity_browser.ui.icons import qicons from activity_browser.bwutils.commontasks import refresh_node diff --git a/activity_browser/actions/activity/activity_new_process.py b/activity_browser/app/actions/activity/activity_new_process.py similarity index 96% rename from activity_browser/actions/activity/activity_new_process.py rename to activity_browser/app/actions/activity/activity_new_process.py index deb42dea2..1ea601192 100644 --- a/activity_browser/actions/activity/activity_new_process.py +++ b/activity_browser/app/actions/activity/activity_new_process.py @@ -5,7 +5,7 @@ from activity_browser import app from activity_browser.bwutils.commontasks import database_is_legacy -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.dialogs.new_node_dialog import NewNodeDialog diff --git a/activity_browser/actions/activity/activity_new_product.py b/activity_browser/app/actions/activity/activity_new_product.py similarity index 98% rename from activity_browser/actions/activity/activity_new_product.py rename to activity_browser/app/actions/activity/activity_new_product.py index 8bee870c5..305ebb39d 100644 --- a/activity_browser/actions/activity/activity_new_product.py +++ b/activity_browser/app/actions/activity/activity_new_product.py @@ -8,7 +8,7 @@ from activity_browser import app from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/activity/activity_open.py b/activity_browser/app/actions/activity/activity_open.py similarity index 96% rename from activity_browser/actions/activity/activity_open.py rename to activity_browser/app/actions/activity/activity_open.py index e09700a37..fe270d1dd 100644 --- a/activity_browser/actions/activity/activity_open.py +++ b/activity_browser/app/actions/activity/activity_open.py @@ -5,7 +5,7 @@ from activity_browser import app from activity_browser.bwutils.commontasks import refresh_node, is_node_process -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/activity/activity_relink.py b/activity_browser/app/actions/activity/activity_relink.py similarity index 95% rename from activity_browser/actions/activity/activity_relink.py rename to activity_browser/app/actions/activity/activity_relink.py index 64a053ab7..69038ff09 100644 --- a/activity_browser/actions/activity/activity_relink.py +++ b/activity_browser/app/actions/activity/activity_relink.py @@ -3,7 +3,7 @@ from qtpy import QtCore, QtWidgets from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils.strategies import relink_activity_exchanges from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -35,7 +35,7 @@ def run(activity_keys: List[tuple]): # present the alternatives to the user in a linking dialog dialog = ActivityLinkingDialog.relink_sqlite( - activity["name"], options, application.main_window + activity["name"], options, app.main_window ) # return if the user cancels @@ -60,7 +60,7 @@ def run(activity_keys: List[tuple]): # if any relinks failed present them to the user if failed > 0: relinking_dialog = ActivityLinkingResultsDialog.present_relinking_results( - application.main_window, relinking_results, examples + app.main_window, relinking_results, examples ) relinking_dialog.exec_() @@ -177,7 +177,7 @@ def construct_results_dialog( link_results: dict = None, unlinked_exchanges: dict = None, ) -> "ActivityLinkingResultsDialog": - from activity_browser import actions + from activity_browser import app obj = cls(parent) for k, results in link_results.items(): @@ -194,7 +194,7 @@ def construct_results_dialog( for act, key in unlinked_exchanges.items(): button = QtWidgets.QPushButton(act.as_dict()["name"]) button.clicked.connect( - lambda: actions.ActivityOpen.run([act.key]) + lambda: app.actions.ActivityOpen.run([act.key]) ) obj.exchangesUnlinked.addWidget(button) obj.updateGeometry() diff --git a/activity_browser/actions/activity/activity_sdf_to_clipboard.py b/activity_browser/app/actions/activity/activity_sdf_to_clipboard.py similarity index 94% rename from activity_browser/actions/activity/activity_sdf_to_clipboard.py rename to activity_browser/app/actions/activity/activity_sdf_to_clipboard.py index 93fbbe0f0..f74a5c690 100644 --- a/activity_browser/actions/activity/activity_sdf_to_clipboard.py +++ b/activity_browser/app/actions/activity/activity_sdf_to_clipboard.py @@ -4,7 +4,7 @@ import bw_functional as bf from activity_browser.bwutils.commontasks import refresh_node, exchanges_to_sdf -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/activity/process_property_modify.py b/activity_browser/app/actions/activity/process_property_modify.py similarity index 98% rename from activity_browser/actions/activity/process_property_modify.py rename to activity_browser/app/actions/activity/process_property_modify.py index cfb30e0f0..26e60b30c 100644 --- a/activity_browser/actions/activity/process_property_modify.py +++ b/activity_browser/app/actions/activity/process_property_modify.py @@ -2,7 +2,7 @@ from activity_browser import app from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from bw_functional import Process diff --git a/activity_browser/actions/activity/process_property_remove.py b/activity_browser/app/actions/activity/process_property_remove.py similarity index 96% rename from activity_browser/actions/activity/process_property_remove.py rename to activity_browser/app/actions/activity/process_property_remove.py index b7677caea..c3c731569 100644 --- a/activity_browser/actions/activity/process_property_remove.py +++ b/activity_browser/app/actions/activity/process_property_remove.py @@ -1,7 +1,7 @@ from loguru import logger from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from bw_functional import Process diff --git a/activity_browser/actions/base.py b/activity_browser/app/actions/base.py similarity index 100% rename from activity_browser/actions/base.py rename to activity_browser/app/actions/base.py diff --git a/activity_browser/actions/calculation_setup/cs_add_functional_unit.py b/activity_browser/app/actions/calculation_setup/cs_add_functional_unit.py similarity index 90% rename from activity_browser/actions/calculation_setup/cs_add_functional_unit.py rename to activity_browser/app/actions/calculation_setup/cs_add_functional_unit.py index 0d026fd06..2171ee4f7 100644 --- a/activity_browser/actions/calculation_setup/cs_add_functional_unit.py +++ b/activity_browser/app/actions/calculation_setup/cs_add_functional_unit.py @@ -1,7 +1,7 @@ from loguru import logger from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd diff --git a/activity_browser/actions/calculation_setup/cs_add_impact_category.py b/activity_browser/app/actions/calculation_setup/cs_add_impact_category.py similarity index 85% rename from activity_browser/actions/calculation_setup/cs_add_impact_category.py rename to activity_browser/app/actions/calculation_setup/cs_add_impact_category.py index 2802c21c8..69a5a34c5 100644 --- a/activity_browser/actions/calculation_setup/cs_add_impact_category.py +++ b/activity_browser/app/actions/calculation_setup/cs_add_impact_category.py @@ -2,7 +2,7 @@ import bw2data as bd -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs diff --git a/activity_browser/actions/calculation_setup/cs_calculate.py b/activity_browser/app/actions/calculation_setup/cs_calculate.py similarity index 97% rename from activity_browser/actions/calculation_setup/cs_calculate.py rename to activity_browser/app/actions/calculation_setup/cs_calculate.py index e8fe423a0..f5e8830eb 100644 --- a/activity_browser/actions/calculation_setup/cs_calculate.py +++ b/activity_browser/app/actions/calculation_setup/cs_calculate.py @@ -9,7 +9,7 @@ from activity_browser.bwutils.multilca import MLCA, Contributions from activity_browser.bwutils.superstructure import SuperstructureMLCA, SuperstructureContributions from activity_browser.bwutils.montecarlo import MonteCarloLCA -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/calculation_setup/cs_change_functional_unit.py b/activity_browser/app/actions/calculation_setup/cs_change_functional_unit.py similarity index 94% rename from activity_browser/actions/calculation_setup/cs_change_functional_unit.py rename to activity_browser/app/actions/calculation_setup/cs_change_functional_unit.py index be08809c6..f7029170a 100644 --- a/activity_browser/actions/calculation_setup/cs_change_functional_unit.py +++ b/activity_browser/app/actions/calculation_setup/cs_change_functional_unit.py @@ -1,6 +1,6 @@ from loguru import logger -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd diff --git a/activity_browser/actions/calculation_setup/cs_delete.py b/activity_browser/app/actions/calculation_setup/cs_delete.py similarity index 95% rename from activity_browser/actions/calculation_setup/cs_delete.py rename to activity_browser/app/actions/calculation_setup/cs_delete.py index b97e3c179..47b88ab29 100644 --- a/activity_browser/actions/calculation_setup/cs_delete.py +++ b/activity_browser/app/actions/calculation_setup/cs_delete.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets from activity_browser.app import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/calculation_setup/cs_delete_functional_unit.py b/activity_browser/app/actions/calculation_setup/cs_delete_functional_unit.py similarity index 86% rename from activity_browser/actions/calculation_setup/cs_delete_functional_unit.py rename to activity_browser/app/actions/calculation_setup/cs_delete_functional_unit.py index 5f07c1b85..654f311e2 100644 --- a/activity_browser/actions/calculation_setup/cs_delete_functional_unit.py +++ b/activity_browser/app/actions/calculation_setup/cs_delete_functional_unit.py @@ -2,7 +2,7 @@ import bw2data as bd -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs diff --git a/activity_browser/actions/calculation_setup/cs_delete_impact_category.py b/activity_browser/app/actions/calculation_setup/cs_delete_impact_category.py similarity index 86% rename from activity_browser/actions/calculation_setup/cs_delete_impact_category.py rename to activity_browser/app/actions/calculation_setup/cs_delete_impact_category.py index ce5cd658f..fa4ad031a 100644 --- a/activity_browser/actions/calculation_setup/cs_delete_impact_category.py +++ b/activity_browser/app/actions/calculation_setup/cs_delete_impact_category.py @@ -2,7 +2,7 @@ import bw2data as bd -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs diff --git a/activity_browser/actions/calculation_setup/cs_duplicate.py b/activity_browser/app/actions/calculation_setup/cs_duplicate.py similarity index 95% rename from activity_browser/actions/calculation_setup/cs_duplicate.py rename to activity_browser/app/actions/calculation_setup/cs_duplicate.py index 14da38b4f..87cfab311 100644 --- a/activity_browser/actions/calculation_setup/cs_duplicate.py +++ b/activity_browser/app/actions/calculation_setup/cs_duplicate.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets from activity_browser.app import application, signals -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/calculation_setup/cs_new.py b/activity_browser/app/actions/calculation_setup/cs_new.py similarity index 95% rename from activity_browser/actions/calculation_setup/cs_new.py rename to activity_browser/app/actions/calculation_setup/cs_new.py index 81cc79156..c5d3cedd7 100644 --- a/activity_browser/actions/calculation_setup/cs_new.py +++ b/activity_browser/app/actions/calculation_setup/cs_new.py @@ -4,8 +4,8 @@ import bw2data as bd -from activity_browser import app, actions -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils.commontasks import refresh_node from activity_browser.ui.icons import qicons @@ -71,7 +71,7 @@ def run(name: str = None, logger.info(f"New calculation setup: {name}") - actions.CSOpen.run(name) + app.actions.CSOpen.run(name) @staticmethod def get_cs_name() -> str | None: diff --git a/activity_browser/actions/calculation_setup/cs_open.py b/activity_browser/app/actions/calculation_setup/cs_open.py similarity index 89% rename from activity_browser/actions/calculation_setup/cs_open.py rename to activity_browser/app/actions/calculation_setup/cs_open.py index 99c64c356..688707b93 100644 --- a/activity_browser/actions/calculation_setup/cs_open.py +++ b/activity_browser/app/actions/calculation_setup/cs_open.py @@ -1,7 +1,7 @@ from loguru import logger from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd diff --git a/activity_browser/actions/calculation_setup/cs_rename.py b/activity_browser/app/actions/calculation_setup/cs_rename.py similarity index 95% rename from activity_browser/actions/calculation_setup/cs_rename.py rename to activity_browser/app/actions/calculation_setup/cs_rename.py index 3f775afc5..419b6e904 100644 --- a/activity_browser/actions/calculation_setup/cs_rename.py +++ b/activity_browser/app/actions/calculation_setup/cs_rename.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets from activity_browser.app import application, signals -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/database/database_delete.py b/activity_browser/app/actions/database/database_delete.py similarity index 98% rename from activity_browser/actions/database/database_delete.py rename to activity_browser/app/actions/database/database_delete.py index 1bf912ab1..a3cade06d 100644 --- a/activity_browser/actions/database/database_delete.py +++ b/activity_browser/app/actions/database/database_delete.py @@ -7,7 +7,7 @@ from bw2data.backends.proxies import ExchangeDataset, Exchanges from activity_browser import app, settings -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/database/database_duplicate.py b/activity_browser/app/actions/database/database_duplicate.py similarity index 97% rename from activity_browser/actions/database/database_duplicate.py rename to activity_browser/app/actions/database/database_duplicate.py index c3119b960..5c8e3db3c 100644 --- a/activity_browser/actions/database/database_duplicate.py +++ b/activity_browser/app/actions/database/database_duplicate.py @@ -6,7 +6,7 @@ import bw_functional as bf from activity_browser.app import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.core.threading import ABThread diff --git a/activity_browser/actions/database/database_explorer_open.py b/activity_browser/app/actions/database/database_explorer_open.py similarity index 89% rename from activity_browser/actions/database/database_explorer_open.py rename to activity_browser/app/actions/database/database_explorer_open.py index 4526fb764..219c0d32e 100644 --- a/activity_browser/actions/database/database_explorer_open.py +++ b/activity_browser/app/actions/database/database_explorer_open.py @@ -1,5 +1,5 @@ from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/database/database_export_bw2package.py b/activity_browser/app/actions/database/database_export_bw2package.py similarity index 98% rename from activity_browser/actions/database/database_export_bw2package.py rename to activity_browser/app/actions/database/database_export_bw2package.py index 72e7910c9..05f7e9485 100644 --- a/activity_browser/actions/database/database_export_bw2package.py +++ b/activity_browser/app/actions/database/database_export_bw2package.py @@ -4,7 +4,7 @@ from qtpy import QtWidgets from activity_browser.app import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import widgets from activity_browser.bwutils import exporters from activity_browser.ui.core import threading diff --git a/activity_browser/actions/database/database_export_excel.py b/activity_browser/app/actions/database/database_export_excel.py similarity index 97% rename from activity_browser/actions/database/database_export_excel.py rename to activity_browser/app/actions/database/database_export_excel.py index 88d47ad4d..c72f0137f 100644 --- a/activity_browser/actions/database/database_export_excel.py +++ b/activity_browser/app/actions/database/database_export_excel.py @@ -4,7 +4,7 @@ from qtpy import QtWidgets from activity_browser.app import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import widgets from activity_browser.bwutils import exporters from activity_browser.ui.core import threading diff --git a/activity_browser/actions/database/database_import_from_ecoinvent.py b/activity_browser/app/actions/database/database_import_from_ecoinvent.py similarity index 99% rename from activity_browser/actions/database/database_import_from_ecoinvent.py rename to activity_browser/app/actions/database/database_import_from_ecoinvent.py index 55c722dde..45792b826 100644 --- a/activity_browser/actions/database/database_import_from_ecoinvent.py +++ b/activity_browser/app/actions/database/database_import_from_ecoinvent.py @@ -14,7 +14,7 @@ from activity_browser.app import application, signals from activity_browser.ui import widgets, icons -from activity_browser.actions.base import ABAction, exception_dialogs +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.mod.bw2io.migrations import ab_create_core_migrations diff --git a/activity_browser/actions/database/database_importer_bw2package.py b/activity_browser/app/actions/database/database_importer_bw2package.py similarity index 97% rename from activity_browser/actions/database/database_importer_bw2package.py rename to activity_browser/app/actions/database/database_importer_bw2package.py index 5e059dc5e..d42cbaa28 100644 --- a/activity_browser/actions/database/database_importer_bw2package.py +++ b/activity_browser/app/actions/database/database_importer_bw2package.py @@ -4,7 +4,7 @@ from qtpy import QtWidgets from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import icons, widgets from activity_browser.bwutils.importers import ABPackage from activity_browser.ui.core import threading diff --git a/activity_browser/actions/database/database_importer_excel.py b/activity_browser/app/actions/database/database_importer_excel.py similarity index 98% rename from activity_browser/actions/database/database_importer_excel.py rename to activity_browser/app/actions/database/database_importer_excel.py index 75f18901c..66249b8ff 100644 --- a/activity_browser/actions/database/database_importer_excel.py +++ b/activity_browser/app/actions/database/database_importer_excel.py @@ -6,7 +6,7 @@ import bw2data as bd from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import widgets from activity_browser.bwutils.importers import ABExcelImporter from activity_browser.ui.core import threading diff --git a/activity_browser/actions/database/database_new.py b/activity_browser/app/actions/database/database_new.py similarity index 97% rename from activity_browser/actions/database/database_new.py rename to activity_browser/app/actions/database/database_new.py index 8f2ed2dda..2933e9d45 100644 --- a/activity_browser/actions/database/database_new.py +++ b/activity_browser/app/actions/database/database_new.py @@ -1,7 +1,7 @@ from qtpy import QtWidgets, QtCore -from activity_browser import settings, app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/database/database_open.py b/activity_browser/app/actions/database/database_open.py similarity index 96% rename from activity_browser/actions/database/database_open.py rename to activity_browser/app/actions/database/database_open.py index d55f29856..3a8ef5623 100644 --- a/activity_browser/actions/database/database_open.py +++ b/activity_browser/app/actions/database/database_open.py @@ -2,7 +2,7 @@ from activity_browser import app from activity_browser.ui import widgets -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs diff --git a/activity_browser/actions/database/database_process.py b/activity_browser/app/actions/database/database_process.py similarity index 83% rename from activity_browser/actions/database/database_process.py rename to activity_browser/app/actions/database/database_process.py index 81ffa73ab..0f5245829 100644 --- a/activity_browser/actions/database/database_process.py +++ b/activity_browser/app/actions/database/database_process.py @@ -1,4 +1,4 @@ -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/database/database_relink.py b/activity_browser/app/actions/database/database_relink.py similarity index 97% rename from activity_browser/actions/database/database_relink.py rename to activity_browser/app/actions/database/database_relink.py index 086b64569..cd3c968c3 100644 --- a/activity_browser/actions/database/database_relink.py +++ b/activity_browser/app/actions/database/database_relink.py @@ -1,7 +1,7 @@ from qtpy import QtCore, QtWidgets from activity_browser.app import application -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils.strategies import relink_exchanges_existing_db from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -187,7 +187,7 @@ def construct_results_dialog( link_results: dict = None, unlinked_exchanges: dict = None, ) -> "DatabaseLinkingResultsDialog": - from activity_browser import actions + from activity_browser import app obj = cls(parent) for k, results in link_results.items(): @@ -204,7 +204,7 @@ def construct_results_dialog( for act, key in unlinked_exchanges.items(): button = QtWidgets.QPushButton(act.as_dict()["name"]) button.clicked.connect( - lambda: actions.ActivityOpen.run([act.key]) + lambda: app.actions.ActivityOpen.run([act.key]) ) obj.exchangesUnlinked.addWidget(button) obj.updateGeometry() diff --git a/activity_browser/actions/database/database_set_readonly.py b/activity_browser/app/actions/database/database_set_readonly.py similarity index 93% rename from activity_browser/actions/database/database_set_readonly.py rename to activity_browser/app/actions/database/database_set_readonly.py index a5aa48d5b..b54696285 100644 --- a/activity_browser/actions/database/database_set_readonly.py +++ b/activity_browser/app/actions/database/database_set_readonly.py @@ -1,4 +1,4 @@ -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd diff --git a/activity_browser/actions/exchange/exchange_copy_sdf.py b/activity_browser/app/actions/exchange/exchange_copy_sdf.py similarity index 89% rename from activity_browser/actions/exchange/exchange_copy_sdf.py rename to activity_browser/app/actions/exchange/exchange_copy_sdf.py index e9b0d1ae7..f7d2c0b8b 100644 --- a/activity_browser/actions/exchange/exchange_copy_sdf.py +++ b/activity_browser/app/actions/exchange/exchange_copy_sdf.py @@ -2,7 +2,7 @@ import pandas as pd -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import commontasks from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/exchange/exchange_delete.py b/activity_browser/app/actions/exchange/exchange_delete.py similarity index 83% rename from activity_browser/actions/exchange/exchange_delete.py rename to activity_browser/app/actions/exchange/exchange_delete.py index e0fc1c023..2a22a2f02 100644 --- a/activity_browser/actions/exchange/exchange_delete.py +++ b/activity_browser/app/actions/exchange/exchange_delete.py @@ -1,6 +1,6 @@ from typing import Any, List -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/exchange/exchange_formula_remove.py b/activity_browser/app/actions/exchange/exchange_formula_remove.py similarity index 89% rename from activity_browser/actions/exchange/exchange_formula_remove.py rename to activity_browser/app/actions/exchange/exchange_formula_remove.py index 2b32680bb..4afdbcbb7 100644 --- a/activity_browser/actions/exchange/exchange_formula_remove.py +++ b/activity_browser/app/actions/exchange/exchange_formula_remove.py @@ -2,7 +2,7 @@ from bw2data.parameters import ParameterizedExchange -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/exchange/exchange_modify.py b/activity_browser/app/actions/exchange/exchange_modify.py similarity index 95% rename from activity_browser/actions/exchange/exchange_modify.py rename to activity_browser/app/actions/exchange/exchange_modify.py index 9c597e0c0..ae52995f4 100644 --- a/activity_browser/actions/exchange/exchange_modify.py +++ b/activity_browser/app/actions/exchange/exchange_modify.py @@ -1,6 +1,6 @@ from bw2data.proxies import ExchangeProxyBase -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from bw2data.parameters import ActivityParameter from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/exchange/exchange_new.py b/activity_browser/app/actions/exchange/exchange_new.py similarity index 89% rename from activity_browser/actions/exchange/exchange_new.py rename to activity_browser/app/actions/exchange/exchange_new.py index e5e05d7f5..6f3dd247c 100644 --- a/activity_browser/actions/exchange/exchange_new.py +++ b/activity_browser/app/actions/exchange/exchange_new.py @@ -1,6 +1,6 @@ from typing import List -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import commontasks from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/exchange/exchange_sdf_to_clipboard.py b/activity_browser/app/actions/exchange/exchange_sdf_to_clipboard.py similarity index 93% rename from activity_browser/actions/exchange/exchange_sdf_to_clipboard.py rename to activity_browser/app/actions/exchange/exchange_sdf_to_clipboard.py index 5c225dbd5..b775d5aad 100644 --- a/activity_browser/actions/exchange/exchange_sdf_to_clipboard.py +++ b/activity_browser/app/actions/exchange/exchange_sdf_to_clipboard.py @@ -4,7 +4,7 @@ import bw_functional as bf from activity_browser.bwutils.commontasks import refresh_edge, exchanges_to_sdf -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/exchange/exchange_uncertainty_modify.py b/activity_browser/app/actions/exchange/exchange_uncertainty_modify.py similarity index 91% rename from activity_browser/actions/exchange/exchange_uncertainty_modify.py rename to activity_browser/app/actions/exchange/exchange_uncertainty_modify.py index e1c3b2303..cc105def1 100644 --- a/activity_browser/actions/exchange/exchange_uncertainty_modify.py +++ b/activity_browser/app/actions/exchange/exchange_uncertainty_modify.py @@ -3,7 +3,7 @@ import bw2data as bd from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.dialogs import UncertaintyDialog diff --git a/activity_browser/actions/exchange/exchange_uncertainty_remove.py b/activity_browser/app/actions/exchange/exchange_uncertainty_remove.py similarity index 88% rename from activity_browser/actions/exchange/exchange_uncertainty_remove.py rename to activity_browser/app/actions/exchange/exchange_uncertainty_remove.py index db96f2625..22ecbd12c 100644 --- a/activity_browser/actions/exchange/exchange_uncertainty_remove.py +++ b/activity_browser/app/actions/exchange/exchange_uncertainty_remove.py @@ -1,6 +1,6 @@ from typing import Any, List -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import uncertainty from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/metadatastore_open.py b/activity_browser/app/actions/metadatastore_open.py similarity index 88% rename from activity_browser/actions/metadatastore_open.py rename to activity_browser/app/actions/metadatastore_open.py index 6ee2c41be..d96a57717 100644 --- a/activity_browser/actions/metadatastore_open.py +++ b/activity_browser/app/actions/metadatastore_open.py @@ -1,7 +1,7 @@ from loguru import logger from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.core.application import global_shortcut diff --git a/activity_browser/actions/method/cf_amount_modify.py b/activity_browser/app/actions/method/cf_amount_modify.py similarity index 92% rename from activity_browser/actions/method/cf_amount_modify.py rename to activity_browser/app/actions/method/cf_amount_modify.py index 8db541b5c..2c5b1cb22 100644 --- a/activity_browser/actions/method/cf_amount_modify.py +++ b/activity_browser/app/actions/method/cf_amount_modify.py @@ -1,4 +1,4 @@ -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/method/cf_new.py b/activity_browser/app/actions/method/cf_new.py similarity index 95% rename from activity_browser/actions/method/cf_new.py rename to activity_browser/app/actions/method/cf_new.py index 159d19c3d..305062496 100644 --- a/activity_browser/actions/method/cf_new.py +++ b/activity_browser/app/actions/method/cf_new.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/method/cf_remove.py b/activity_browser/app/actions/method/cf_remove.py similarity index 94% rename from activity_browser/actions/method/cf_remove.py rename to activity_browser/app/actions/method/cf_remove.py index 3db87d594..5b79b7a83 100644 --- a/activity_browser/actions/method/cf_remove.py +++ b/activity_browser/app/actions/method/cf_remove.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/method/cf_uncertainty_modify.py b/activity_browser/app/actions/method/cf_uncertainty_modify.py similarity index 94% rename from activity_browser/actions/method/cf_uncertainty_modify.py rename to activity_browser/app/actions/method/cf_uncertainty_modify.py index 420843ef9..ac794fec5 100644 --- a/activity_browser/actions/method/cf_uncertainty_modify.py +++ b/activity_browser/app/actions/method/cf_uncertainty_modify.py @@ -2,7 +2,7 @@ from typing import List from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons from activity_browser.ui.dialogs import UncertaintyDialog diff --git a/activity_browser/actions/method/cf_uncertainty_remove.py b/activity_browser/app/actions/method/cf_uncertainty_remove.py similarity index 94% rename from activity_browser/actions/method/cf_uncertainty_remove.py rename to activity_browser/app/actions/method/cf_uncertainty_remove.py index 7389d8011..c26a4ad6d 100644 --- a/activity_browser/actions/method/cf_uncertainty_remove.py +++ b/activity_browser/app/actions/method/cf_uncertainty_remove.py @@ -1,6 +1,6 @@ from typing import List -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/method/importer/method_importer_bw2io.py b/activity_browser/app/actions/method/importer/method_importer_bw2io.py similarity index 96% rename from activity_browser/actions/method/importer/method_importer_bw2io.py rename to activity_browser/app/actions/method/importer/method_importer_bw2io.py index 40ce2bf41..cc2fc9c29 100644 --- a/activity_browser/actions/method/importer/method_importer_bw2io.py +++ b/activity_browser/app/actions/method/importer/method_importer_bw2io.py @@ -4,7 +4,7 @@ from qtpy.QtCore import Signal, SignalInstance from activity_browser import app -from activity_browser.actions.base import exception_dialogs +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 diff --git a/activity_browser/actions/method/importer/method_importer_ecoinvent.py b/activity_browser/app/actions/method/importer/method_importer_ecoinvent.py similarity index 98% rename from activity_browser/actions/method/importer/method_importer_ecoinvent.py rename to activity_browser/app/actions/method/importer/method_importer_ecoinvent.py index 684649aca..a847e3b1b 100644 --- a/activity_browser/actions/method/importer/method_importer_ecoinvent.py +++ b/activity_browser/app/actions/method/importer/method_importer_ecoinvent.py @@ -5,7 +5,7 @@ from activity_browser import app from activity_browser.mod import bw2data as bd -from activity_browser.actions.base import ABAction, exception_dialogs +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.core import threading diff --git a/activity_browser/actions/method/method_delete.py b/activity_browser/app/actions/method/method_delete.py similarity index 97% rename from activity_browser/actions/method/method_delete.py rename to activity_browser/app/actions/method/method_delete.py index f4f4093b5..dac6cd4b0 100644 --- a/activity_browser/actions/method/method_delete.py +++ b/activity_browser/app/actions/method/method_delete.py @@ -5,7 +5,7 @@ from qtpy import QtWidgets from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/method/method_duplicate.py b/activity_browser/app/actions/method/method_duplicate.py similarity index 98% rename from activity_browser/actions/method/method_duplicate.py rename to activity_browser/app/actions/method/method_duplicate.py index 1068b7ff4..fb63a406a 100644 --- a/activity_browser/actions/method/method_duplicate.py +++ b/activity_browser/app/actions/method/method_duplicate.py @@ -4,7 +4,7 @@ from qtpy import QtWidgets from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/method/method_meta_modify.py b/activity_browser/app/actions/method/method_meta_modify.py similarity index 88% rename from activity_browser/actions/method/method_meta_modify.py rename to activity_browser/app/actions/method/method_meta_modify.py index 244caa371..f917f82ff 100644 --- a/activity_browser/actions/method/method_meta_modify.py +++ b/activity_browser/app/actions/method/method_meta_modify.py @@ -1,6 +1,6 @@ from loguru import logger -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/method/method_new.py b/activity_browser/app/actions/method/method_new.py similarity index 97% rename from activity_browser/actions/method/method_new.py rename to activity_browser/app/actions/method/method_new.py index e65cd0c70..8fa03e688 100644 --- a/activity_browser/actions/method/method_new.py +++ b/activity_browser/app/actions/method/method_new.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons from activity_browser.ui import dialogs diff --git a/activity_browser/actions/method/method_open.py b/activity_browser/app/actions/method/method_open.py similarity index 91% rename from activity_browser/actions/method/method_open.py rename to activity_browser/app/actions/method/method_open.py index e61ef0d5f..c6f9cc49d 100644 --- a/activity_browser/actions/method/method_open.py +++ b/activity_browser/app/actions/method/method_open.py @@ -1,7 +1,7 @@ from typing import List from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/method/method_rename.py b/activity_browser/app/actions/method/method_rename.py similarity index 98% rename from activity_browser/actions/method/method_rename.py rename to activity_browser/app/actions/method/method_rename.py index 8c91296b1..7942dd8a6 100644 --- a/activity_browser/actions/method/method_rename.py +++ b/activity_browser/app/actions/method/method_rename.py @@ -7,7 +7,7 @@ from activity_browser import app from activity_browser.ui import dialogs -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs diff --git a/activity_browser/actions/migrations_install.py b/activity_browser/app/actions/migrations_install.py similarity index 93% rename from activity_browser/actions/migrations_install.py rename to activity_browser/app/actions/migrations_install.py index 4d0687c7f..753445abb 100644 --- a/activity_browser/actions/migrations_install.py +++ b/activity_browser/app/actions/migrations_install.py @@ -1,7 +1,7 @@ from qtpy import QtWidgets from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import icons from activity_browser.mod.bw2io.migrations import ab_create_core_migrations from activity_browser.ui.core import threading diff --git a/activity_browser/actions/parameter/parameter_clear_broken.py b/activity_browser/app/actions/parameter/parameter_clear_broken.py similarity index 94% rename from activity_browser/actions/parameter/parameter_clear_broken.py rename to activity_browser/app/actions/parameter/parameter_clear_broken.py index 4c7b1879e..f020bb355 100644 --- a/activity_browser/actions/parameter/parameter_clear_broken.py +++ b/activity_browser/app/actions/parameter/parameter_clear_broken.py @@ -1,6 +1,6 @@ from typing import Any -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from bw2data.parameters import (ActivityParameter, Group, GroupDependency, parameters) diff --git a/activity_browser/actions/parameter/parameter_delete.py b/activity_browser/app/actions/parameter/parameter_delete.py similarity index 96% rename from activity_browser/actions/parameter/parameter_delete.py rename to activity_browser/app/actions/parameter/parameter_delete.py index 7a6823bc6..d1f335297 100644 --- a/activity_browser/actions/parameter/parameter_delete.py +++ b/activity_browser/app/actions/parameter/parameter_delete.py @@ -1,7 +1,7 @@ from typing import Any from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from bw2data import get_activity from bw2data.parameters import (ActivityParameter, Group, GroupDependency, diff --git a/activity_browser/actions/parameter/parameter_modify.py b/activity_browser/app/actions/parameter/parameter_modify.py similarity index 96% rename from activity_browser/actions/parameter/parameter_modify.py rename to activity_browser/app/actions/parameter/parameter_modify.py index 3d888605a..85528c687 100644 --- a/activity_browser/actions/parameter/parameter_modify.py +++ b/activity_browser/app/actions/parameter/parameter_modify.py @@ -7,7 +7,7 @@ from activity_browser.ui.icons import qicons from activity_browser.bwutils.commontasks import refresh_parameter from activity_browser.bwutils.utils import Parameter -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from .parameter_rename import ParameterRename diff --git a/activity_browser/actions/parameter/parameter_new.py b/activity_browser/app/actions/parameter/parameter_new.py similarity index 97% rename from activity_browser/actions/parameter/parameter_new.py rename to activity_browser/app/actions/parameter/parameter_new.py index f6eac5152..fea852592 100644 --- a/activity_browser/actions/parameter/parameter_new.py +++ b/activity_browser/app/actions/parameter/parameter_new.py @@ -2,8 +2,8 @@ from qtpy import QtCore, QtGui, QtWidgets -from activity_browser import actions, app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import commontasks as bc from activity_browser.mod import bw2data as bd from bw2data.parameters import ActivityParameter @@ -99,7 +99,7 @@ def _get_group(self): ) if not ActivityParameter.select().where(query).count(): - actions.ParameterNewAutomatic.run([self.key]) + app.actions.ParameterNewAutomatic.run([self.key]) return ActivityParameter.get(query).group diff --git a/activity_browser/actions/parameter/parameter_new_automatic.py b/activity_browser/app/actions/parameter/parameter_new_automatic.py similarity index 95% rename from activity_browser/actions/parameter/parameter_new_automatic.py rename to activity_browser/app/actions/parameter/parameter_new_automatic.py index ed141c306..4fd69599d 100644 --- a/activity_browser/actions/parameter/parameter_new_automatic.py +++ b/activity_browser/app/actions/parameter/parameter_new_automatic.py @@ -5,7 +5,7 @@ from activity_browser import app from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from bw2data.parameters import ActivityParameter from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/parameter/parameter_new_from_parameter.py b/activity_browser/app/actions/parameter/parameter_new_from_parameter.py similarity index 96% rename from activity_browser/actions/parameter/parameter_new_from_parameter.py rename to activity_browser/app/actions/parameter/parameter_new_from_parameter.py index 52fe1569c..0ba0757b0 100644 --- a/activity_browser/actions/parameter/parameter_new_from_parameter.py +++ b/activity_browser/app/actions/parameter/parameter_new_from_parameter.py @@ -1,6 +1,6 @@ from ast import literal_eval -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils.utils import Parameter from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, parameters from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/parameter/parameter_rename.py b/activity_browser/app/actions/parameter/parameter_rename.py similarity index 95% rename from activity_browser/actions/parameter/parameter_rename.py rename to activity_browser/app/actions/parameter/parameter_rename.py index 6172dcf55..8fa768498 100644 --- a/activity_browser/actions/parameter/parameter_rename.py +++ b/activity_browser/app/actions/parameter/parameter_rename.py @@ -3,7 +3,7 @@ from bw2data.parameters import ParameterBase, parameters from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.bwutils.utils import Parameter from activity_browser.bwutils.commontasks import refresh_parameter diff --git a/activity_browser/actions/parameter/parameter_uncertainty_modify.py b/activity_browser/app/actions/parameter/parameter_uncertainty_modify.py similarity index 92% rename from activity_browser/actions/parameter/parameter_uncertainty_modify.py rename to activity_browser/app/actions/parameter/parameter_uncertainty_modify.py index 956acb164..ea8ba7e87 100644 --- a/activity_browser/actions/parameter/parameter_uncertainty_modify.py +++ b/activity_browser/app/actions/parameter/parameter_uncertainty_modify.py @@ -2,7 +2,7 @@ import bw2data as bd -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser import app from activity_browser.ui.dialogs import UncertaintyDialog from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/parameter/parameter_uncertainty_remove.py b/activity_browser/app/actions/parameter/parameter_uncertainty_remove.py similarity index 88% rename from activity_browser/actions/parameter/parameter_uncertainty_remove.py rename to activity_browser/app/actions/parameter/parameter_uncertainty_remove.py index 2ffe04ebd..7210bc8d2 100644 --- a/activity_browser/actions/parameter/parameter_uncertainty_remove.py +++ b/activity_browser/app/actions/parameter/parameter_uncertainty_remove.py @@ -1,6 +1,6 @@ from typing import Any -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils import uncertainty from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/project/project_create_template.py b/activity_browser/app/actions/project/project_create_template.py similarity index 97% rename from activity_browser/actions/project/project_create_template.py rename to activity_browser/app/actions/project/project_create_template.py index 14d92bd4e..b08fedc08 100644 --- a/activity_browser/actions/project/project_create_template.py +++ b/activity_browser/app/actions/project/project_create_template.py @@ -8,7 +8,7 @@ from activity_browser import app from activity_browser.mod import bw2data as bd -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.core.threading import ABThread diff --git a/activity_browser/actions/project/project_delete.py b/activity_browser/app/actions/project/project_delete.py similarity index 98% rename from activity_browser/actions/project/project_delete.py rename to activity_browser/app/actions/project/project_delete.py index 038efb1d6..0a645ee98 100644 --- a/activity_browser/actions/project/project_delete.py +++ b/activity_browser/app/actions/project/project_delete.py @@ -7,7 +7,7 @@ from bw2data.utils import safe_filename from activity_browser import settings, app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from .project_switch import ProjectSwitch diff --git a/activity_browser/actions/project/project_duplicate.py b/activity_browser/app/actions/project/project_duplicate.py similarity index 96% rename from activity_browser/actions/project/project_duplicate.py rename to activity_browser/app/actions/project/project_duplicate.py index 82f7fef2e..c6701491b 100644 --- a/activity_browser/actions/project/project_duplicate.py +++ b/activity_browser/app/actions/project/project_duplicate.py @@ -1,7 +1,7 @@ from qtpy import QtWidgets from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/project/project_export.py b/activity_browser/app/actions/project/project_export.py similarity index 97% rename from activity_browser/actions/project/project_export.py rename to activity_browser/app/actions/project/project_export.py index 06c3cbc83..8b9a83f28 100644 --- a/activity_browser/actions/project/project_export.py +++ b/activity_browser/app/actions/project/project_export.py @@ -9,7 +9,7 @@ from bw2data.project import ProjectDataset from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.core.threading import ABThread diff --git a/activity_browser/actions/project/project_import.py b/activity_browser/app/actions/project/project_import.py similarity index 98% rename from activity_browser/actions/project/project_import.py rename to activity_browser/app/actions/project/project_import.py index 36fac12b4..002797b9a 100644 --- a/activity_browser/actions/project/project_import.py +++ b/activity_browser/app/actions/project/project_import.py @@ -9,7 +9,7 @@ from activity_browser import app from activity_browser.mod import bw2data as bd -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.core.threading import ABThread diff --git a/activity_browser/actions/project/project_local_import.py b/activity_browser/app/actions/project/project_local_import.py similarity index 99% rename from activity_browser/actions/project/project_local_import.py rename to activity_browser/app/actions/project/project_local_import.py index 3be01a8b2..b4029806f 100644 --- a/activity_browser/actions/project/project_local_import.py +++ b/activity_browser/app/actions/project/project_local_import.py @@ -5,7 +5,7 @@ from qtpy import QtWidgets, QtCore from bw2io import restore_project_directory -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui import icons, widgets diff --git a/activity_browser/actions/project/project_manager_open.py b/activity_browser/app/actions/project/project_manager_open.py similarity index 90% rename from activity_browser/actions/project/project_manager_open.py rename to activity_browser/app/actions/project/project_manager_open.py index e09c6c56d..5bada520f 100644 --- a/activity_browser/actions/project/project_manager_open.py +++ b/activity_browser/app/actions/project/project_manager_open.py @@ -1,7 +1,7 @@ from qtpy import QtCore from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/project/project_migrate25.py b/activity_browser/app/actions/project/project_migrate25.py similarity index 98% rename from activity_browser/actions/project/project_migrate25.py rename to activity_browser/app/actions/project/project_migrate25.py index 14b61e359..209bd4364 100644 --- a/activity_browser/actions/project/project_migrate25.py +++ b/activity_browser/app/actions/project/project_migrate25.py @@ -6,7 +6,7 @@ import pandas as pd from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.core.threading import ABThread diff --git a/activity_browser/actions/project/project_new.py b/activity_browser/app/actions/project/project_new.py similarity index 94% rename from activity_browser/actions/project/project_new.py rename to activity_browser/app/actions/project/project_new.py index d7e3f6d79..52ad7d26a 100644 --- a/activity_browser/actions/project/project_new.py +++ b/activity_browser/app/actions/project/project_new.py @@ -1,7 +1,7 @@ from qtpy import QtWidgets from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/project/project_new_remote.py b/activity_browser/app/actions/project/project_new_remote.py similarity index 95% rename from activity_browser/actions/project/project_new_remote.py rename to activity_browser/app/actions/project/project_new_remote.py index ac3d727ec..d61923592 100644 --- a/activity_browser/actions/project/project_new_remote.py +++ b/activity_browser/app/actions/project/project_new_remote.py @@ -3,7 +3,7 @@ import bw2data as bd from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod.bw2io import remote from activity_browser.ui.icons import qicons from activity_browser.ui.core.threading import ABThread diff --git a/activity_browser/actions/project/project_new_template.py b/activity_browser/app/actions/project/project_new_template.py similarity index 97% rename from activity_browser/actions/project/project_new_template.py rename to activity_browser/app/actions/project/project_new_template.py index 79544f3a6..0c07bae3e 100644 --- a/activity_browser/actions/project/project_new_template.py +++ b/activity_browser/app/actions/project/project_new_template.py @@ -5,7 +5,7 @@ from bw2io import backup from activity_browser import app, utils -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.core.threading import ABThread from activity_browser.ui.icons import qicons diff --git a/activity_browser/actions/project/project_remote_import.py b/activity_browser/app/actions/project/project_remote_import.py similarity index 99% rename from activity_browser/actions/project/project_remote_import.py rename to activity_browser/app/actions/project/project_remote_import.py index 7cefe002f..924b8cc16 100644 --- a/activity_browser/actions/project/project_remote_import.py +++ b/activity_browser/app/actions/project/project_remote_import.py @@ -7,7 +7,7 @@ from bw2io import install_project import requests -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui import icons, widgets diff --git a/activity_browser/actions/project/project_switch.py b/activity_browser/app/actions/project/project_switch.py similarity index 97% rename from activity_browser/actions/project/project_switch.py rename to activity_browser/app/actions/project/project_switch.py index cb09c50b1..a573337ab 100644 --- a/activity_browser/actions/project/project_switch.py +++ b/activity_browser/app/actions/project/project_switch.py @@ -6,7 +6,7 @@ import bw2data as bd from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from .project_migrate25 import ProjectMigrate25 diff --git a/activity_browser/actions/pyside_upgrade.py b/activity_browser/app/actions/pyside_upgrade.py similarity index 97% rename from activity_browser/actions/pyside_upgrade.py rename to activity_browser/app/actions/pyside_upgrade.py index e3038ee89..1ed892b77 100644 --- a/activity_browser/actions/pyside_upgrade.py +++ b/activity_browser/app/actions/pyside_upgrade.py @@ -5,7 +5,7 @@ import time from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import icons from qtpy import QtWidgets diff --git a/activity_browser/actions/settings_wizard_open.py b/activity_browser/app/actions/settings_wizard_open.py similarity index 84% rename from activity_browser/actions/settings_wizard_open.py rename to activity_browser/app/actions/settings_wizard_open.py index f2b1e026e..8118611fc 100644 --- a/activity_browser/actions/settings_wizard_open.py +++ b/activity_browser/app/actions/settings_wizard_open.py @@ -1,5 +1,5 @@ from activity_browser import app -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons from activity_browser.ui.wizards.settings_wizard import SettingsWizard diff --git a/activity_browser/actions/tools/bw2io/tools_bw2io_migrations.py b/activity_browser/app/actions/tools/bw2io/tools_bw2io_migrations.py similarity index 84% rename from activity_browser/actions/tools/bw2io/tools_bw2io_migrations.py rename to activity_browser/app/actions/tools/bw2io/tools_bw2io_migrations.py index b027a0c33..70e214027 100644 --- a/activity_browser/actions/tools/bw2io/tools_bw2io_migrations.py +++ b/activity_browser/app/actions/tools/bw2io/tools_bw2io_migrations.py @@ -1,4 +1,4 @@ -from activity_browser.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/app/menu_bar.py b/activity_browser/app/menu_bar.py index 759f5d1e7..c70241f0d 100644 --- a/activity_browser/app/menu_bar.py +++ b/activity_browser/app/menu_bar.py @@ -5,7 +5,7 @@ from qtpy import QtGui, QtWidgets from qtpy.QtCore import QSize, QUrl -from activity_browser import actions, utils, app +from activity_browser import app, utils, app from ..ui.icons import qicons @@ -40,14 +40,14 @@ def __init__(self, parent=None) -> None: self.setTitle("&Project") - self.dup_proj_action = actions.ProjectDuplicate.get_QAction() - self.delete_proj_action = actions.ProjectDelete.get_QAction() + self.dup_proj_action = app.actions.ProjectDuplicate.get_QAction() + self.delete_proj_action = app.actions.ProjectDelete.get_QAction() - self.import_proj_action = actions.ProjectImport.get_QAction() - self.export_proj_action = actions.ProjectExport.get_QAction() + self.import_proj_action = app.actions.ProjectImport.get_QAction() + self.export_proj_action = app.actions.ProjectExport.get_QAction() - self.manage_settings_action = actions.SettingsWizardOpen.get_QAction() - self.manage_projects_action = actions.ProjectManagerOpen.get_QAction() + self.manage_settings_action = app.actions.SettingsWizardOpen.get_QAction() + self.manage_projects_action = app.actions.ProjectManagerOpen.get_QAction() self.addMenu(ProjectSelectionMenu(self)) self.addMenu(ProjectNewMenu(self)) @@ -71,8 +71,8 @@ def __init__(self, parent=None) -> None: super().__init__(parent) self.setTitle("New project") - self.new_proj_action = actions.ProjectNew.get_QAction() - self.import_proj_action = actions.ProjectImport.get_QAction() + self.new_proj_action = app.actions.ProjectNew.get_QAction() + self.import_proj_action = app.actions.ProjectImport.get_QAction() self.new_proj_action.setText("Empty project") self.import_proj_action.setText("From .tar.gz file") @@ -95,13 +95,13 @@ def __init__(self, parent=None): self.actions = {} for key in utils.get_templates(): - action = actions.ProjectNewFromTemplate.get_QAction(key) + action = app.actions.ProjectNewFromTemplate.get_QAction(key) action.setText(key) self.actions[key] = action self.addAction(action) for key in self.get_projects(): - action = actions.ProjectNewRemote.get_QAction(key) + action = app.actions.ProjectNewRemote.get_QAction(key) action.setText(key) self.actions[key] = action self.addAction(action) @@ -133,7 +133,7 @@ def __init__(self, parent=None) -> None: self.setTitle("&Calculate") self.cs_actions = [] - self.new_cs_action = actions.CSNew.get_QAction() + self.new_cs_action = app.actions.CSNew.get_QAction() self.new_cs_action.setText("New setup...") self.addAction(self.new_cs_action) self.addSeparator() @@ -144,7 +144,7 @@ def __init__(self, parent=None) -> None: def sync(self): self.cs_actions.clear() for cs in bd.calculation_setups: - action = actions.CSOpen.get_QAction(cs) + action = app.actions.CSOpen.get_QAction(cs) action.setText(cs) self.cs_actions.append(action) self.addAction(action) @@ -220,7 +220,7 @@ def __init__(self, parent=None): self.populate() self.aboutToShow.connect(self.populate) - self.triggered.connect(lambda act: actions.ProjectSwitch.run(act.data())) + self.triggered.connect(lambda act: app.actions.ProjectSwitch.run(act.data())) def populate(self): """ @@ -256,9 +256,9 @@ def __init__(self, parent=None) -> None: self.setTitle("Import database") self.setIcon(qicons.import_db) - self.import_from_ecoinvent_action = actions.DatabaseImportFromEcoinvent.get_QAction() - self.import_from_excel_action = actions.DatabaseImporterExcel.get_QAction() - self.import_from_bw2package_action = actions.DatabaseImporterBW2Package.get_QAction() + self.import_from_ecoinvent_action = app.actions.DatabaseImportFromEcoinvent.get_QAction() + self.import_from_excel_action = app.actions.DatabaseImporterExcel.get_QAction() + self.import_from_bw2package_action = app.actions.DatabaseImporterBW2Package.get_QAction() self.import_from_ecoinvent_action.setText("ecoinvent...") self.import_from_excel_action.setText("from .xlsx") @@ -275,8 +275,8 @@ def __init__(self, parent=None) -> None: super().__init__(parent=parent) self.setTitle("Export database") - self.export_to_excel_action = actions.DatabaseExportExcel.get_QAction() - self.export_to_bw2package_action = actions.DatabaseExportBW2Package.get_QAction() + self.export_to_excel_action = app.actions.DatabaseExportExcel.get_QAction() + self.export_to_bw2package_action = app.actions.DatabaseExportBW2Package.get_QAction() self.export_to_excel_action.setText("to .xlsx") self.export_to_bw2package_action.setText("to .bw2package") @@ -294,8 +294,8 @@ def __init__(self, parent=None) -> None: self.beta_warning = QtWidgets.QWidgetAction(self) self.beta_warning.setDefaultWidget(QtWidgets.QLabel("Beta features, use at your own risk")) - self.import_from_ei_excel_action = actions.MethodImporterEcoinvent.get_QAction() - self.import_from_bw2io_action = actions.MethodImporterBW2IO.get_QAction() + self.import_from_ei_excel_action = app.actions.MethodImporterEcoinvent.get_QAction() + self.import_from_bw2io_action = app.actions.MethodImporterBW2IO.get_QAction() self.import_from_ei_excel_action.setText("from ecoinvent excel") self.import_from_bw2io_action.setText("from bw2io") diff --git a/activity_browser/app/pages/activity_details/activity_header.py b/activity_browser/app/pages/activity_details/activity_header.py index ea6070fdc..dcb259beb 100644 --- a/activity_browser/app/pages/activity_details/activity_header.py +++ b/activity_browser/app/pages/activity_details/activity_header.py @@ -3,7 +3,7 @@ import bw2data as bd import bw_functional as bf -from activity_browser import app, actions +from activity_browser import app, app from activity_browser.bwutils.commontasks import refresh_node, database_is_locked from activity_browser.ui import widgets @@ -134,7 +134,7 @@ def change_name(self): """ if self.text() == self.parent().activity["name"]: return - actions.ActivityModify.run(self.parent().activity, "name", self.text()) + app.actions.ActivityModify.run(self.parent().activity, "name", self.text()) class ActivityLocation(QtWidgets.QLineEdit): @@ -162,7 +162,7 @@ def change_location(self): """ if self.text() == self.parent().activity.get("location"): return - actions.ActivityModify.run(self.parent().activity, "location", self.text()) + app.actions.ActivityModify.run(self.parent().activity, "location", self.text()) class ActivityProperties(QtWidgets.QWidget): @@ -190,7 +190,7 @@ def __init__(self, parent: ActivityHeader): layout.addWidget(ActivityProperty(parent.activity, property_name)) add_label = QtWidgets.QLabel("Add property") - add_label.mouseReleaseEvent = lambda x: actions.ProcessPropertyModify.run(parent.activity) + add_label.mouseReleaseEvent = lambda x: app.actions.ProcessPropertyModify.run(parent.activity) layout.addWidget(add_label) @@ -212,8 +212,8 @@ def __init__(self, activity, property_name): """ super().__init__(property_name, None) - self.modify_action = actions.ProcessPropertyModify.get_QAction(activity, property_name) - self.remove_action = actions.ProcessPropertyRemove.get_QAction(activity, property_name) + self.modify_action = app.actions.ProcessPropertyModify.get_QAction(activity, property_name) + self.remove_action = app.actions.ProcessPropertyRemove.get_QAction(activity, property_name) self.menu = QtWidgets.QMenu(self) self.menu.addAction(self.modify_action) @@ -278,7 +278,7 @@ def change_allocation(self, allocation: str): act = self.parent().activity if act.get("allocation") == allocation: return - actions.ActivityModify.run(act, "allocation", allocation) + app.actions.ActivityModify.run(act, "allocation", allocation) class LockedWarningBar(QtWidgets.QToolBar): @@ -296,7 +296,7 @@ def __init__(self, parent: ActivityHeader): warning_icon.setPixmap(pixmap) migrate_label = QtWidgets.QLabel("Unlock database") - migrate_label.mouseReleaseEvent = lambda x: actions.DatabaseSetReadonly.run(parent.activity["database"], False) + migrate_label.mouseReleaseEvent = lambda x: app.actions.DatabaseSetReadonly.run(parent.activity["database"], False) self.addWidget(warning_icon) self.addWidget(warning_label) diff --git a/activity_browser/app/pages/activity_details/consumers_tab.py b/activity_browser/app/pages/activity_details/consumers_tab.py index 73c959369..4260c97c5 100644 --- a/activity_browser/app/pages/activity_details/consumers_tab.py +++ b/activity_browser/app/pages/activity_details/consumers_tab.py @@ -4,7 +4,7 @@ import bw2data as bd import bw_functional as bf -from activity_browser import actions, app +from activity_browser import app, app from activity_browser.bwutils.commontasks import refresh_node from activity_browser.ui import widgets, icons @@ -109,7 +109,7 @@ def mouseDoubleClickEvent(self, event) -> None: items = [i.internalPointer() for i in self.selectedIndexes() if isinstance(i.internalPointer(), ConsumersItem)] keys = list({i["_consumer_key"] for i in items}) if keys: - actions.ActivityOpen.run(keys) + app.actions.ActivityOpen.run(keys) class ConsumersItem(widgets.ABDataItem): diff --git a/activity_browser/app/pages/activity_details/data_tab.py b/activity_browser/app/pages/activity_details/data_tab.py index 729d32534..48ab6c3e9 100644 --- a/activity_browser/app/pages/activity_details/data_tab.py +++ b/activity_browser/app/pages/activity_details/data_tab.py @@ -4,7 +4,7 @@ import bw2data as bd import bw_functional as bf -from activity_browser import actions +from activity_browser import app from activity_browser.bwutils.commontasks import refresh_node, database_is_locked from activity_browser.ui import widgets, delegates @@ -152,7 +152,7 @@ def setData(self, col: int, key: str, value) -> bool: """ if key in ["value"]: value = eval(value) - actions.ActivityModify.run(self["_activity_id"], self["field"], value) + app.actions.ActivityModify.run(self["_activity_id"], self["field"], value) return False diff --git a/activity_browser/app/pages/activity_details/description_tab.py b/activity_browser/app/pages/activity_details/description_tab.py index 03e8b84cd..c4b6c6533 100644 --- a/activity_browser/app/pages/activity_details/description_tab.py +++ b/activity_browser/app/pages/activity_details/description_tab.py @@ -2,7 +2,7 @@ import bw2data as bd -from activity_browser import actions +from activity_browser import app from activity_browser.bwutils.commontasks import refresh_node, database_is_locked @@ -45,4 +45,4 @@ def focusOutEvent(self, e): """ if self.toPlainText() == self.activity.get("comment", ""): return - actions.ActivityModify.run(self.activity, "comment", self.toPlainText()) + app.actions.ActivityModify.run(self.activity, "comment", self.toPlainText()) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index 37b51095d..fda673d92 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -8,7 +8,7 @@ import bw_functional as bf -from activity_browser import actions, app +from activity_browser import app, app from activity_browser.bwutils.commontasks import refresh_node, database_is_locked, database_is_legacy, is_node_product, is_node_biosphere, parameters_in_scope from activity_browser.ui import widgets, icons, delegates @@ -229,7 +229,7 @@ def dropEvent(self, event): # Run the action for new exchanges for exc_type, keys in exchanges.items(): - actions.ExchangeNew.run(keys, self.activity.key, exc_type) + app.actions.ExchangeNew.run(keys, self.activity.key, exc_type) def get_exchange_type(activity_key: tuple) -> str | None: if is_node_product(activity_key): @@ -286,7 +286,7 @@ def setModelData(self, editor: QtWidgets.QComboBox, model, index): choice = editor.currentIndex() key = self.matched.iloc[choice].key - actions.ExchangeModify.run( + app.actions.ExchangeModify.run( index.internalPointer().exchange, {"input": key} ) @@ -376,7 +376,7 @@ def setup_allocation(self): if database_is_locked(table_view.activity["database"]) or not self.column.startswith("property"): return - action = actions.ActivityModify.get_QAction(table_view.activity.key, + action = app.actions.ActivityModify.get_QAction(table_view.activity.key, "allocation", self.column[9:], parent=self) @@ -391,17 +391,17 @@ def column(self): class ContextMenu(widgets.ABMenu): menuSetup = [ - lambda m: m.add(actions.ActivityNewProduct, [m.activity.key], + lambda m: m.add(app.actions.ActivityNewProduct, [m.activity.key], enable=not m.locked and not database_is_legacy(m.activity["database"]) ), - lambda m: m.add(actions.ActivityNewProduct, [m.activity.key], "waste", + lambda m: m.add(app.actions.ActivityNewProduct, [m.activity.key], "waste", enable=not m.locked and not database_is_legacy(m.activity["database"]), text="Create waste" ), lambda m: m.addSeparator(), - lambda m: m.add(actions.ExchangeDelete, m.exchanges, enable=bool(m.exchanges) and not m.locked), - lambda m: m.add(actions.ExchangeSDFToClipboard, m.exchanges, enable=bool(m.exchanges)), - lambda m: m.add(actions.ActivityOpen, [x.input for x in m.exchanges], + lambda m: m.add(app.actions.ExchangeDelete, m.exchanges, enable=bool(m.exchanges) and not m.locked), + lambda m: m.add(app.actions.ExchangeSDFToClipboard, m.exchanges, enable=bool(m.exchanges)), + lambda m: m.add(app.actions.ActivityOpen, [x.input for x in m.exchanges], enable=bool(m.exchanges), text="Open processs" if len(m.exchanges) == 1 else "Open processes", ), @@ -661,15 +661,15 @@ def setData(self, col: int, key: str, value) -> bool: """ if key in ["amount", "formula", "comment"]: if key == "formula" and not str(value).strip(): - actions.ExchangeFormulaRemove.run([self.exchange]) + app.actions.ExchangeFormulaRemove.run([self.exchange]) return True - actions.ExchangeModify.run(self.exchange, {key.lower(): value}) + app.actions.ExchangeModify.run(self.exchange, {key.lower(): value}) return True if key in ["unit", "product", "location", "substitution_factor", "allocation_factor"]: act = self.exchange.input - actions.ActivityModify.run(act.key, key.lower(), value) + app.actions.ActivityModify.run(act.key, key.lower(), value) if key.startswith("property_"): # should move this process to a separate action @@ -687,7 +687,7 @@ def setData(self, col: int, key: str, value) -> bool: props = product.get("properties", {}) props[prop_key] = prop - actions.ActivityModify.run(product, "properties", props) + app.actions.ActivityModify.run(product, "properties", props) return False diff --git a/activity_browser/app/pages/activity_details/graph_tab.py b/activity_browser/app/pages/activity_details/graph_tab.py index 41ded8290..5ebd35ce1 100644 --- a/activity_browser/app/pages/activity_details/graph_tab.py +++ b/activity_browser/app/pages/activity_details/graph_tab.py @@ -8,7 +8,7 @@ import bw2data as bd import bw_functional as bf -from activity_browser import static, actions +from activity_browser import static, app from activity_browser.bwutils.commontasks import refresh_node, database_is_locked from activity_browser.ui import widgets from .exchanges_tab import get_exchange_type @@ -244,7 +244,7 @@ def dropEvent(self, event): # Run the action for new exchanges for exc_type, keys in exchanges.items(): - actions.ExchangeNew.run(keys, self.parent().activity.key, exc_type) + app.actions.ExchangeNew.run(keys, self.parent().activity.key, exc_type) class Bridge(QObject): diff --git a/activity_browser/app/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py index 6434ef416..e76e245f6 100644 --- a/activity_browser/app/pages/activity_details/parameters_tab.py +++ b/activity_browser/app/pages/activity_details/parameters_tab.py @@ -3,7 +3,7 @@ import pandas as pd import bw2data as bd -from activity_browser import app, actions +from activity_browser import app, app from activity_browser.ui import widgets, icons, delegates from activity_browser.bwutils.commontasks import refresh_node, refresh_parameter, parameters_in_scope, database_is_locked, node_group from activity_browser.bwutils.utils import Parameter @@ -134,7 +134,7 @@ def __init__(self, pos, view: "ParametersView"): if index.isValid() and isinstance(index.internalPointer(), ParametersItem): item = index.internalPointer() param = item.parameter.to_peewee_model() - self.del_param_action = actions.ParameterDelete().get_QAction(param) + self.del_param_action = app.actions.ParameterDelete().get_QAction(param) if not param.is_deletable() or param.name == "dummy_parameter": self.del_param_action.setEnabled(False) self.addAction(self.del_param_action) @@ -195,7 +195,7 @@ def setData(self, col: int, key: str, value) -> bool: bool: True if the data was set successfully, False otherwise. """ if key in ["amount", "formula", "name", "comment"]: - actions.ParameterModify.run(self.parameter, key, value) + app.actions.ParameterModify.run(self.parameter, key, value) return False @@ -275,7 +275,7 @@ def setData(self, col: int, key: str, value) -> bool: param_type=self["_parameter"]["param_type"] ) - actions.ParameterNewFromParameter.run(parameter) + app.actions.ParameterNewFromParameter.run(parameter) return True diff --git a/activity_browser/app/pages/calculation_setup/calculation_setup.py b/activity_browser/app/pages/calculation_setup/calculation_setup.py index 5fb95c80e..c2a9f0775 100644 --- a/activity_browser/app/pages/calculation_setup/calculation_setup.py +++ b/activity_browser/app/pages/calculation_setup/calculation_setup.py @@ -1,6 +1,6 @@ from qtpy import QtWidgets -from activity_browser import app, actions +from activity_browser import app, app from activity_browser.ui import widgets, icons from .scenario_section import ScenarioSection @@ -83,8 +83,8 @@ def type_switch(self, calculation_type: str): def run_calculation(self): if self.type_dropdown.currentText() == "Standard": - actions.CSCalculate.run(self.calculation_setup_name) + app.actions.CSCalculate.run(self.calculation_setup_name) elif self.type_dropdown.currentText() == "Scenario": scenario_data = self.scenario_section.scenario_dataframe() - actions.CSCalculate.run(self.calculation_setup_name, scenario_data) + app.actions.CSCalculate.run(self.calculation_setup_name, scenario_data) diff --git a/activity_browser/app/pages/calculation_setup/functional_unit_section.py b/activity_browser/app/pages/calculation_setup/functional_unit_section.py index f896c5b14..19c3391ca 100644 --- a/activity_browser/app/pages/calculation_setup/functional_unit_section.py +++ b/activity_browser/app/pages/calculation_setup/functional_unit_section.py @@ -4,7 +4,7 @@ import bw2data as bd import pandas as pd -from activity_browser import actions, app +from activity_browser import app, app from activity_browser.ui import widgets, icons, delegates from activity_browser.bwutils.commontasks import is_node_product @@ -89,12 +89,12 @@ class FunctionalUnitView(widgets.ABTreeView): class ContextMenu(widgets.ABMenu): menuSetup = [ - lambda m, p: m.add(actions.ActivityOpen, m.selected_processes, + lambda m, p: m.add(app.actions.ActivityOpen, m.selected_processes, text="Open process" if len(m.selected_processes) == 1 else "Open processes", enable=len(m.selected_processes) > 0 ), lambda m: m.addSeparator(), - lambda m, p: m.add(actions.CSDeleteFunctionalUnit, m.cs_name, m.selected_fus, + lambda m, p: m.add(app.actions.CSDeleteFunctionalUnit, m.cs_name, m.selected_fus, text="Delete Functional Unit" if len(m.selected_fus) == 1 else "Delete Functional Units", enable=len(m.selected_fus) > 0 ), @@ -132,7 +132,7 @@ def mouseDoubleClickEvent(self, event) -> None: if self.selectedIndexes(): activities = [index.internalPointer()["_processor_key"] for index in self.selectedIndexes()] - actions.ActivityOpen.run(list(set(activities))) + app.actions.ActivityOpen.run(list(set(activities))) return None @@ -160,7 +160,7 @@ def dropEvent(self, event) -> None: if not is_node_product(key): keys.remove(key) - actions.CSAddFunctionalUnit.run(cs_name, keys) + app.actions.CSAddFunctionalUnit.run(cs_name, keys) class FunctionalUnitItem(widgets.ABDataItem): @@ -209,7 +209,7 @@ def setData(self, col: int, key: str, value) -> bool: cs_name = self["_cs_name"] index = self.key() - actions.CSChangeFunctionalUnit.run(cs_name, index, value) + app.actions.CSChangeFunctionalUnit.run(cs_name, index, value) diff --git a/activity_browser/app/pages/calculation_setup/impact_category_section.py b/activity_browser/app/pages/calculation_setup/impact_category_section.py index eafb72b56..1105405e6 100644 --- a/activity_browser/app/pages/calculation_setup/impact_category_section.py +++ b/activity_browser/app/pages/calculation_setup/impact_category_section.py @@ -3,7 +3,7 @@ import bw2data as bd import pandas as pd -from activity_browser import actions +from activity_browser import app from activity_browser.ui import widgets, delegates @@ -59,7 +59,7 @@ def __init__(self, pos, view: "ImpactCategoryView"): indices = [index.internalPointer().key() for index in view.selectedIndexes()] - self.delete_ic_action = actions.CSDeleteImpactCategory.get_QAction(cs_name, indices) + self.delete_ic_action = app.actions.CSDeleteImpactCategory.get_QAction(cs_name, indices) print(self.delete_ic_action.text()) self.addAction(self.delete_ic_action) @@ -80,7 +80,7 @@ def dropEvent(self, event) -> None: event.accept() cs_name = self.parent().calculation_setup_name method_names = event.mimeData().retrievePickleData("application/bw-methodnamelist") - actions.CSAddImpactCategory.run(cs_name, method_names) + app.actions.CSAddImpactCategory.run(cs_name, method_names) class ImpactCategoryItem(widgets.ABDataItem): diff --git a/activity_browser/app/pages/impact_category_details/impact_category_details.py b/activity_browser/app/pages/impact_category_details/impact_category_details.py index 9ab124a22..12fd61883 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_details.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_details.py @@ -4,7 +4,7 @@ import bw2data as bd import pandas as pd -from activity_browser import actions, app +from activity_browser import app, app from activity_browser.ui import widgets, icons, delegates from activity_browser.bwutils.commontasks import is_node_biosphere @@ -90,7 +90,7 @@ class CharacterizationFactorsView(widgets.ABTreeView): class ContextMenu(widgets.ABMenu): menuSetup = [ - lambda m: m.add(actions.CFRemove, m.impact_category_name, m.char_factors, + lambda m: m.add(app.actions.CFRemove, m.impact_category_name, m.char_factors, enable=bool(m.char_factors) and m.is_editable, text="Remove characterization factor(s)"), ] @@ -172,7 +172,7 @@ def dropEvent(self, event): biosphere_keys = [key for key in keys if is_node_biosphere(key)] if biosphere_keys: - actions.CFNew.run(self.parent().name, biosphere_keys) + app.actions.CFNew.run(self.parent().name, biosphere_keys) class CharacterizationFactorsItem(widgets.ABDataItem): @@ -240,7 +240,7 @@ def setData(self, col: int, key: str, value) -> bool: if key not in ["amount"]: return False - actions.CFAmountModify.run(self["_impact_category_name"], self["_id"], value) + app.actions.CFAmountModify.run(self["_impact_category_name"], self["_id"], value) class CharacterizationFactorsModel(widgets.ABItemModel): diff --git a/activity_browser/app/pages/impact_category_details/impact_category_header.py b/activity_browser/app/pages/impact_category_details/impact_category_header.py index c853bdc0d..2b26afbd9 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_header.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_header.py @@ -1,5 +1,5 @@ from qtpy import QtWidgets, QtCore -from activity_browser import actions +from activity_browser import app from activity_browser.ui import widgets @@ -153,7 +153,7 @@ def _rename_method(self): """ Triggers the method rename action. """ - actions.MethodRename.run(self.parent().impact_category.name) + app.actions.MethodRename.run(self.parent().impact_category.name) class ImpactCategoryUnit(QtWidgets.QLineEdit): @@ -181,4 +181,4 @@ def change_unit(self): if self.text() == current_unit: return - actions.MethodMetaModify.run(impact_category.name, "unit", self.text()) \ No newline at end of file + app.actions.MethodMetaModify.run(impact_category.name, "unit", self.text()) \ No newline at end of file diff --git a/activity_browser/app/pages/parameters/parameter_models.py b/activity_browser/app/pages/parameters/parameter_models.py index d6fad62c6..cbc25e8df 100644 --- a/activity_browser/app/pages/parameters/parameter_models.py +++ b/activity_browser/app/pages/parameters/parameter_models.py @@ -12,7 +12,7 @@ from bw2data.parameters import ActivityParameter, DatabaseParameter, Group, ProjectParameter -from activity_browser import actions, signals, application +from activity_browser import app, signals, application from activity_browser.bwutils.utils import Parameters from activity_browser.mod import bw2data as bd from activity_browser.ui.dialogs import UncertaintyWizard @@ -86,7 +86,7 @@ def edit_single_parameter(self, index: QModelIndex) -> None: param = self.get_parameter(index) field = self._dataframe.columns[index.column()] - actions.ParameterModify.run(param, field, index.data()) + app.actions.ParameterModify.run(param, field, index.data()) @Slot(QModelIndex, name="startRenameParameter") @@ -94,11 +94,11 @@ def handle_parameter_rename(self, proxy: QModelIndex) -> None: group = self.get_group(proxy) param = self.get_parameter(proxy) - actions.ParameterRename.run(param) + app.actions.ParameterRename.run(param) def delete_parameter(self, proxy: QModelIndex) -> None: param = self.get_parameter(proxy) - actions.ParameterDelete.run(param) + app.actions.ParameterDelete.run(param) @Slot(name="modifyParameterUncertainty") def modify_uncertainty(self, proxy: QModelIndex) -> None: @@ -109,7 +109,7 @@ def modify_uncertainty(self, proxy: QModelIndex) -> None: @Slot(name="unsetParameterUncertainty") def remove_uncertainty(self, proxy: QModelIndex) -> None: param = self.get_parameter(proxy) - actions.ParameterUncertaintyRemove.run(param) + app.actions.ParameterUncertaintyRemove.run(param) def handle_double_click(self, proxy: QModelIndex) -> None: column = proxy.column() @@ -241,7 +241,7 @@ def parse_parameter(cls, parameter) -> dict: logger.info( "Activity {} no longer exists, removing parameter.".format(row["key"]) ) - actions.ParameterClearBroken.run(parameter) + app.actions.ParameterClearBroken.run(parameter) return {} row["product"] = act.get("reference product") or act.get("name") row["activity"] = act.get("name") @@ -365,7 +365,7 @@ def build_exchanges(cls, act_param, parent: TreeItem) -> None: except DoesNotExist as e: # The exchange is coming from a deleted database, remove it logger.warning(f"Broken exchange: {exc}, removing.") - actions.ExchangeDelete.run([exc]) + app.actions.ExchangeDelete.run([exc]) class ParameterTreeModel(BaseTreeModel): diff --git a/activity_browser/app/pages/parameters/parameter_views.py b/activity_browser/app/pages/parameters/parameter_views.py index 6e6d18766..acdbe0a3e 100644 --- a/activity_browser/app/pages/parameters/parameter_views.py +++ b/activity_browser/app/pages/parameters/parameter_views.py @@ -6,7 +6,7 @@ import bw2data as bd import bw_functional as bf -from activity_browser import actions, signals +from activity_browser import app, signals from activity_browser.ui import icons, delegates from .parameter_models import ( @@ -227,7 +227,7 @@ def dropEvent(self, event: QDropEvent) -> None: continue processes.add(key) event.accept() - actions.ParameterNewAutomatic.run(processes) + app.actions.ParameterNewAutomatic.run(processes) def contextMenuEvent(self, event: QContextMenuEvent) -> None: """Override and activate QTableView.contextMenuEvent() diff --git a/activity_browser/app/pages/parameters/parameters.py b/activity_browser/app/pages/parameters/parameters.py index 966b04ab0..c9ef3421b 100644 --- a/activity_browser/app/pages/parameters/parameters.py +++ b/activity_browser/app/pages/parameters/parameters.py @@ -8,7 +8,7 @@ from qtpy import QtWidgets, QtCore from qtpy.QtCore import Qt -from activity_browser import actions, signals +from activity_browser import app, signals from activity_browser.ui import icons, widgets from activity_browser.bwutils import manager, superstructure @@ -84,7 +84,7 @@ def get_table(self): class ABProjectParameter(ABParameterTable): def __init__(self, parent=None): super().__init__(parent) - self.new_parameter_button = actions.ParameterNew.get_QButton(("", "")) + self.new_parameter_button = app.actions.ParameterNew.get_QButton(("", "")) self.header = "Project:" self.table = ProjectParameterTable(self) @@ -98,7 +98,7 @@ def __init__(self, parent=None): super().__init__(parent) self.header = "Database:" - self.new_parameter_button = actions.ParameterNew.get_QButton(("db", "")) + self.new_parameter_button = app.actions.ParameterNew.get_QButton(("db", "")) self.table = DataBaseParameterTable(self) diff --git a/activity_browser/app/pages/parameters/parameters_new.py b/activity_browser/app/pages/parameters/parameters_new.py index 365cfcdc5..e2799d982 100644 --- a/activity_browser/app/pages/parameters/parameters_new.py +++ b/activity_browser/app/pages/parameters/parameters_new.py @@ -5,7 +5,7 @@ from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, ParameterizedExchange from bw2data.backends import ExchangeDataset -from activity_browser import app, actions +from activity_browser import app, app from activity_browser.ui import widgets, icons, delegates from activity_browser.bwutils.commontasks import refresh_parameter, refresh_node, database_is_locked from activity_browser.bwutils.utils import Parameter @@ -257,7 +257,7 @@ def __init__(self, pos, view: "ProjectParametersView"): if index.isValid() and isinstance(index.internalPointer(), ProjectParametersItem): item = index.internalPointer() param = item.parameter.to_peewee_model() - self.del_param_action = actions.ParameterDelete().get_QAction(param) + self.del_param_action = app.actions.ParameterDelete().get_QAction(param) if not param.is_deletable() or param.name == "dummy_parameter": self.del_param_action.setEnabled(False) self.addAction(self.del_param_action) @@ -324,7 +324,7 @@ def setData(self, col: int, key: str, value) -> bool: bool: True if the data was set successfully, False otherwise. """ if key in ["amount", "formula", "name", "comment"]: - actions.ParameterModify.run(self.parameter, key, value) + app.actions.ParameterModify.run(self.parameter, key, value) return False @@ -405,7 +405,7 @@ def setData(self, col: int, key: str, value) -> bool: param_type=self["_param_type"] ) - actions.ParameterNewFromParameter.run(parameter) + app.actions.ParameterNewFromParameter.run(parameter) return True @@ -549,7 +549,7 @@ def __init__(self, pos, view: "ParameterizedExchangesView"): item = index.internalPointer() # Open activity action - open_action = actions.ActivityOpen.get_QAction([item["_output_key"]]) + open_action = app.actions.ActivityOpen.get_QAction([item["_output_key"]]) open_action.setText("Open activity") self.addAction(open_action) @@ -617,10 +617,10 @@ def setData(self, col: int, key: str, value) -> bool: """ if key in ["amount", "formula", "comment"]: if key == "formula" and not str(value).strip(): - actions.ExchangeFormulaRemove.run([self.exchange]) + app.actions.ExchangeFormulaRemove.run([self.exchange]) return True - actions.ExchangeModify.run(self.exchange, {key.lower(): value}) + app.actions.ExchangeModify.run(self.exchange, {key.lower(): value}) return True return False diff --git a/activity_browser/app/pages/welcome.py b/activity_browser/app/pages/welcome.py index bd5988fe9..25ef4fb65 100644 --- a/activity_browser/app/pages/welcome.py +++ b/activity_browser/app/pages/welcome.py @@ -2,7 +2,7 @@ from qtpy import QtWebEngineWidgets, QtWidgets, QtCore, QtGui, QtWebChannel -from activity_browser import actions, app +from activity_browser import app, app from activity_browser.static import startscreen from activity_browser.bwutils.commontasks import projects_by_last_opened @@ -63,7 +63,7 @@ def open_project(self, project_name): """ Emits the ready signal. """ - actions.ProjectSwitch.run(project_name) + app.actions.ProjectSwitch.run(project_name) class WelcomeWebPage(QtWebEngineWidgets.QWebEnginePage): def acceptNavigationRequest(self, qurl, navtype, mainframe): diff --git a/activity_browser/app/panes/calculation_setups.py b/activity_browser/app/panes/calculation_setups.py index c0ff7198b..f4b9fe8b3 100644 --- a/activity_browser/app/panes/calculation_setups.py +++ b/activity_browser/app/panes/calculation_setups.py @@ -3,7 +3,7 @@ import bw2data as bd import pandas as pd -from activity_browser import app, actions +from activity_browser import app, app from activity_browser.ui import widgets, delegates, core @@ -92,15 +92,15 @@ class CalculationSetupsView(widgets.ABNewTreeView): class ContextMenu(widgets.ABMenu): menuSetup = [ - lambda m, p: m.add(actions.CSNew), - lambda m, p: m.add(actions.CSOpen, p.calculation_setups, + lambda m, p: m.add(app.actions.CSNew), + lambda m, p: m.add(app.actions.CSOpen, p.calculation_setups, enable=bool(p.calculation_setups)), - lambda m, p: m.add(actions.CSDelete, p.calculation_setups, + lambda m, p: m.add(app.actions.CSDelete, p.calculation_setups, enable=bool(p.calculation_setups)), - lambda m, p: m.add(actions.CSRename, p.calculation_setups[0] if p.single_selection else None, + lambda m, p: m.add(app.actions.CSRename, p.calculation_setups[0] if p.single_selection else None, enable=p.single_selection), lambda m: m.addSeparator(), - lambda m, p: m.add(actions.CSCalculate, p.calculation_setups[0] if p.single_selection else None, + lambda m, p: m.add(app.actions.CSCalculate, p.calculation_setups[0] if p.single_selection else None, enable=p.single_selection), ] @@ -143,7 +143,7 @@ def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent): if row is None: return - actions.CSOpen.run(row["name"]) + app.actions.CSOpen.run(row["name"]) def dragMoveEvent(self, event) -> None: @@ -173,7 +173,7 @@ def dropEvent(self, event) -> None: functional_units = [{key: 1.0} for key in keys] - actions.CSNew.run(functional_units=functional_units) + app.actions.CSNew.run(functional_units=functional_units) class CalculationSetupsModel(core.ABTreeModel): diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 0e83e3268..cefb588e0 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -7,7 +7,7 @@ import bw2data as bd -from activity_browser import actions, ui, app +from activity_browser import app, ui, app from activity_browser.settings import project_settings from activity_browser.ui import core, widgets, delegates, icons from activity_browser.bwutils.commontasks import database_is_locked, database_is_legacy @@ -218,40 +218,40 @@ class ProductView(ui.widgets.ABNewTreeView): class ContextMenu(ui.widgets.ABMenu): menuSetup = [ - lambda m, p: m.add(actions.ActivityOpen, p.selected_activities, + lambda m, p: m.add(app.actions.ActivityOpen, p.selected_activities, text="Open process" if len(p.selected_activities) == 1 else "Open processes", enable=len(p.selected_activities) > 0 ), lambda m: m.addSeparator(), - lambda m, p: m.add(actions.ActivityNewProcess, p.db_name, + lambda m, p: m.add(app.actions.ActivityNewProcess, p.db_name, enable=not database_is_locked(p.db_name), ), - lambda m, p: m.add(actions.ActivityDuplicate, p.selected_activities, + lambda m, p: m.add(app.actions.ActivityDuplicate, p.selected_activities, text="Duplicate process" if len(p.selected_activities) == 1 else "Duplicate processes", enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), ), - lambda m, p: m.add(actions.ActivityDuplicateToDB, p.selected_activities, + lambda m, p: m.add(app.actions.ActivityDuplicateToDB, p.selected_activities, text="Duplicate process to database" if len(p.selected_activities) == 1 else "Duplicate processes to database", enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), ), lambda m: m.addSeparator(), - lambda m, p: m.add(actions.ActivityDelete, p.selected_activities, + lambda m, p: m.add(app.actions.ActivityDelete, p.selected_activities, text="Delete process" if len(p.selected_activities) == 1 else "Delete processes", enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), ), - lambda m, p: m.add(actions.ActivityDelete, p.selected_products, + lambda m, p: m.add(app.actions.ActivityDelete, p.selected_products, text="Delete product" if len(p.selected_products) == 1 else "Delete products", enable=len(p.selected_products) > 0 and not database_is_locked(p.db_name) and not database_is_legacy(p.db_name), ), lambda m: m.addSeparator(), - lambda m, p: m.add(actions.CSNew, + lambda m, p: m.add(app.actions.CSNew, functional_units=[{prod: m.get_functional_unit_amount(prod)} for prod in p.selected_products], enable=len(p.selected_products) > 0, text="Create setup" ), - lambda m, p: m.add(actions.ActivitySDFToClipboard, p.selected_products, + lambda m, p: m.add(app.actions.ActivitySDFToClipboard, p.selected_products, enable=len(p.selected_products) > 0, ), ] @@ -303,7 +303,7 @@ def mouseDoubleClickEvent(self, event) -> None: event: The mouse double click event. """ if self.selected_activities: - actions.ActivityOpen.run(self.selected_activities) + app.actions.ActivityOpen.run(self.selected_activities) @property def selected_products(self) -> list[tuple]: diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py index 7cef124dd..1dd29e450 100644 --- a/activity_browser/app/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -6,7 +6,7 @@ import bw2data as bd import pandas as pd -from activity_browser import app, actions +from activity_browser import app, app from activity_browser.bwutils.commontasks import count_database_records from activity_browser.ui import widgets, icons, delegates, core from activity_browser.app.menu_bar import ImportDatabaseMenu @@ -114,11 +114,11 @@ class DatabasesView(widgets.ABNewTreeView): class ExportDatabaseContextMenu(widgets.ABMenu): menuSetup = [ lambda m: m.setTitle("Export database" if len(m.parent().selected_databases) == 1 else "Export databases"), - lambda m, p: m.add(actions.DatabaseExportExcel, p.selected_databases if p.selected_databases else [], + lambda m, p: m.add(app.actions.DatabaseExportExcel, p.selected_databases if p.selected_databases else [], enable=len(p.selected_databases) >= 1, text="to .xlsx", ), - lambda m, p: m.add(actions.DatabaseExportBW2Package, p.selected_databases if p.selected_databases else [], + lambda m, p: m.add(app.actions.DatabaseExportBW2Package, p.selected_databases if p.selected_databases else [], enable=len(p.selected_databases) >= 1, text="to .bw2package", ), @@ -126,20 +126,20 @@ class ExportDatabaseContextMenu(widgets.ABMenu): class ContextMenu(widgets.ABMenu): menuSetup = [ - lambda m, p: m.add(actions.DatabaseNew), + lambda m, p: m.add(app.actions.DatabaseNew), lambda m: m.addMenu(ImportDatabaseMenu(m)), lambda m, p: m.addMenu(DatabasesView.ExportDatabaseContextMenu(parent=p)), lambda m: m.addSeparator(), - lambda m, p: m.add(actions.DatabaseDelete, p.selected_databases if p.selected_databases else [], + lambda m, p: m.add(app.actions.DatabaseDelete, p.selected_databases if p.selected_databases else [], enable=len(p.selected_databases) >= 1, text="Delete databases" if len(p.selected_databases) > 1 else "Delete database", ), - lambda m, p: m.add(actions.DatabaseDuplicate, p.selected_databases[0] if p.selected_databases else None, + lambda m, p: m.add(app.actions.DatabaseDuplicate, p.selected_databases[0] if p.selected_databases else None, enable=len(p.selected_databases) == 1), - lambda m, p: m.add(actions.DatabaseProcess, p.selected_databases[0] if p.selected_databases else None, + lambda m, p: m.add(app.actions.DatabaseProcess, p.selected_databases[0] if p.selected_databases else None, enable=len(p.selected_databases) == 1), lambda m: m.addSeparator(), - lambda m, p: m.add(actions.DatabaseSetReadonly, p.selected_databases[0] if p.selected_databases else None, + lambda m, p: m.add(app.actions.DatabaseSetReadonly, p.selected_databases[0] if p.selected_databases else None, not m.selected_readonly, enable=len(p.selected_databases) == 1, text="Unlock database" if m.selected_readonly else "Lock database", @@ -188,10 +188,10 @@ def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent): if index.column() == 1: read_only = row.get("read_only") - actions.DatabaseSetReadonly.run(db_name, not read_only) + app.actions.DatabaseSetReadonly.run(db_name, not read_only) return - actions.DatabaseOpen.run([db_name]) + app.actions.DatabaseOpen.run([db_name]) def keyPressEvent(self, event: QtGui.QKeyEvent): """ @@ -202,7 +202,7 @@ def keyPressEvent(self, event: QtGui.QKeyEvent): """ if event.key() == Qt.Key_Delete: if self.selected_databases: - actions.DatabaseDelete.run(self.selected_databases) + app.actions.DatabaseDelete.run(self.selected_databases) return super().keyPressEvent(event) diff --git a/activity_browser/app/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py index 9d12a17da..0af672c63 100644 --- a/activity_browser/app/panes/impact_categories.py +++ b/activity_browser/app/panes/impact_categories.py @@ -4,7 +4,7 @@ import bw2data as bd import pandas as pd -from activity_browser import app, actions +from activity_browser import app, app from activity_browser.ui import widgets, core, delegates @@ -81,22 +81,22 @@ class ImpactCategoriesView(widgets.ABNewTreeView): class ContextMenu(widgets.ABMenu): menuSetup = [ - lambda m, p: m.add(actions.MethodNew), + lambda m, p: m.add(app.actions.MethodNew), lambda m: m.addSeparator(), - lambda m, p: m.add(actions.MethodOpen, p.selected_impact_categories, + lambda m, p: m.add(app.actions.MethodOpen, p.selected_impact_categories, text="Open impact category" if len(p.selected_impact_categories) == 1 else "Open impact categories", enable=len(p.selected_impact_categories) > 0 ), - lambda m, p: m.add(actions.MethodDelete, p.selected_impact_categories, + lambda m, p: m.add(app.actions.MethodDelete, p.selected_impact_categories, text="Delete impact category" if len( p.selected_impact_categories) == 1 else "Delete impact categories", enable=len(p.selected_impact_categories) > 0 ), - lambda m, p: m.add(actions.MethodDuplicate, p.selected_impact_categories, + lambda m, p: m.add(app.actions.MethodDuplicate, p.selected_impact_categories, text="Duplicate impact category", enable=len(p.selected_impact_categories) == 1 ), - lambda m, p: m.add(actions.MethodRename, p.selected_impact_categories, + lambda m, p: m.add(app.actions.MethodRename, p.selected_impact_categories, text="Rename impact category", enable=len(p.selected_impact_categories) == 1 ), @@ -117,7 +117,7 @@ def selected_impact_categories(self): def mouseDoubleClickEvent(self, event) -> None: if self.selected_impact_categories: - actions.MethodOpen.run(self.selected_impact_categories) + app.actions.MethodOpen.run(self.selected_impact_categories) class ImpactCategoriesModel(core.ABTreeModel): diff --git a/activity_browser/app/panes/project_manager.py b/activity_browser/app/panes/project_manager.py index 0f6afe894..4afeaf734 100644 --- a/activity_browser/app/panes/project_manager.py +++ b/activity_browser/app/panes/project_manager.py @@ -6,7 +6,7 @@ import bw2data as bd from bw2io import remote -from activity_browser import actions, ui, app, utils +from activity_browser import app, ui, app, utils from activity_browser.settings import ab_settings from activity_browser.ui import widgets @@ -108,16 +108,16 @@ def __init__(self, pos, view: "FunctionView"): return if len(items) == 1: - self.dup_project = actions.ProjectDuplicate.get_QAction(items[0]["Name"]) - self.template_project = actions.ProjectCreateTemplate.get_QAction(items[0]["Name"], view.parent()) + self.dup_project = app.actions.ProjectDuplicate.get_QAction(items[0]["Name"]) + self.template_project = app.actions.ProjectCreateTemplate.get_QAction(items[0]["Name"], view.parent()) self.addAction(self.dup_project) self.addAction(self.template_project) if len(items) == 1 and len([i for i in items if i["Version"] == "Legacy"]) == 1: - self.migrate_project = actions.ProjectMigrate25.get_QAction(items[0]["Name"]) + self.migrate_project = app.actions.ProjectMigrate25.get_QAction(items[0]["Name"]) self.addAction(self.migrate_project) - self.del_project = actions.ProjectDelete.get_QAction(view.selected_projects) + self.del_project = app.actions.ProjectDelete.get_QAction(view.selected_projects) self.addAction(self.del_project) diff --git a/activity_browser/app/signals.py b/activity_browser/app/signalling.py similarity index 100% rename from activity_browser/app/signals.py rename to activity_browser/app/signalling.py diff --git a/activity_browser/ui/delegates/formula.py b/activity_browser/ui/delegates/formula.py index 3600616ff..9533acf39 100644 --- a/activity_browser/ui/delegates/formula.py +++ b/activity_browser/ui/delegates/formula.py @@ -5,7 +5,7 @@ from qtpy import QtCore, QtGui, QtWidgets from qtpy.QtCore import Signal, Slot -from activity_browser import actions, app +from activity_browser import app, app class CalculatorButtons(QtWidgets.QWidget): @@ -98,7 +98,7 @@ def __init__(self, parent=None, flags=QtCore.Qt.Window): self.text_field.setCompleter(completer) self.parameters.doubleClicked.connect(self.append_parameter_name) - self.new_parameter_button = actions.ParameterNew.get_QButton(self.get_key) + self.new_parameter_button = app.actions.ParameterNew.get_QButton(self.get_key) self.calculator = CalculatorButtons(self) self.calculator.button_press.connect(self.text_field.insert) diff --git a/activity_browser/ui/delegates/uncertainty.py b/activity_browser/ui/delegates/uncertainty.py index db97997ab..74b5411ae 100644 --- a/activity_browser/ui/delegates/uncertainty.py +++ b/activity_browser/ui/delegates/uncertainty.py @@ -2,7 +2,7 @@ from qtpy import QtCore, QtWidgets from stats_arrays import uncertainty_choices as uc -from activity_browser import actions +from activity_browser import app from activity_browser.app import signals @@ -35,11 +35,11 @@ def createEditor(self, parent, option, index): item_name = item.__class__.__name__ if item_name == "ParametersItem" or item_name == "ProjectParametersItem": - actions.ParameterUncertaintyModify.run(item["_parameter"].to_peewee_model()) + app.actions.ParameterUncertaintyModify.run(item["_parameter"].to_peewee_model()) elif item_name == "ExchangesItem": - actions.ExchangeUncertaintyModify.run([item.exchange]) + app.actions.ExchangeUncertaintyModify.run([item.exchange]) elif item_name == "CharacterizationFactorsItem": - actions.CFUncertaintyModify.run( + app.actions.CFUncertaintyModify.run( item["_impact_category_name"], [(item["_id"], item["_cf"]),] ) diff --git a/activity_browser/ui/dialogs/uncertainty.py b/activity_browser/ui/dialogs/uncertainty.py index a35a4dcdf..7978cc05c 100644 --- a/activity_browser/ui/dialogs/uncertainty.py +++ b/activity_browser/ui/dialogs/uncertainty.py @@ -8,7 +8,7 @@ from stats_arrays import uncertainty_choices as uncertainty from stats_arrays.distributions import * -from activity_browser import actions, app +from activity_browser import app, app from activity_browser.ui.widgets.plot import ABPlot from activity_browser.bwutils.pedigree import PedigreeMatrix from activity_browser.bwutils.uncertainty import get_uncertainty_interface, EMPTY_UNCERTAINTY @@ -81,15 +81,15 @@ def update_uncertainty(self): """ self.amount_mean_test() if self.obj.data_type == "exchange": - actions.ExchangeModify.run(self.obj.data, self.uncertainty_info) + app.actions.ExchangeModify.run(self.obj.data, self.uncertainty_info) if self.using_pedigree: - actions.ExchangeModify.run( + app.actions.ExchangeModify.run( self.obj.data, {"pedigree": self.pedigree.matrix.factors} ) elif self.obj.data_type == "parameter": - actions.ParameterModify.run(self.obj.data, "data", self.uncertainty_info) + app.actions.ParameterModify.run(self.obj.data, "data", self.uncertainty_info) if self.using_pedigree: - actions.ParameterModify.run( + app.actions.ParameterModify.run( self.obj.data, "data", self.pedigree.matrix.factors ) elif self.obj.data_type == "cf": @@ -156,11 +156,11 @@ def amount_mean_test(self) -> None: ) if choice == QtWidgets.QMessageBox.Yes: if self.obj.data_type == "exchange": - actions.ExchangeModify.run(self.obj.data, {"amount": mean}) + app.actions.ExchangeModify.run(self.obj.data, {"amount": mean}) elif self.obj.data_type == "parameter": try: - actions.ParameterModify.run(self.obj.data, "amount", mean) + app.actions.ParameterModify.run(self.obj.data, "amount", mean) except Exception as e: QtWidgets.QMessageBox.warning( app.main_window, diff --git a/activity_browser/ui/widgets/line_edit.py b/activity_browser/ui/widgets/line_edit.py index 655d269d5..b009fb742 100644 --- a/activity_browser/ui/widgets/line_edit.py +++ b/activity_browser/ui/widgets/line_edit.py @@ -49,12 +49,12 @@ def _text_changed(self, text: str) -> None: @Slot(name="customEditFinish") def _editing_finished(self) -> None: - from activity_browser import actions + from activity_browser import app after = self.text() if self._before != after: self._before = after - actions.ActivityModify.run(self._key, self._field, after) + app.actions.ActivityModify.run(self._key, self._field, after) class SignalledPlainTextEdit(QtWidgets.QPlainTextEdit): @@ -78,11 +78,11 @@ def highlight(self): self.setExtraSelections([selection]) def focusOutEvent(self, event): - from activity_browser import actions + from activity_browser import app after = self.toPlainText() if self._before != after: - actions.ActivityModify.run(self._key, self._field, after) + app.actions.ActivityModify.run(self._key, self._field, after) super().focusOutEvent(event) def refresh_text(self, text: str) -> None: @@ -104,12 +104,12 @@ def __init__(self, key, field, contents="", parent=None): self._field = field def focusOutEvent(self, event): - from activity_browser import actions + from activity_browser import app after = self.currentText() if self._before != after: self._before = after - actions.ActivityModify.run(self._key, self._field, after) + app.actions.ActivityModify.run(self._key, self._field, after) super(SignalledComboEdit, self).focusOutEvent(event) diff --git a/tests/actions/test_activity_actions.py b/tests/actions/test_activity_actions.py index afcba0607..fb9df0f17 100644 --- a/tests/actions/test_activity_actions.py +++ b/tests/actions/test_activity_actions.py @@ -3,7 +3,7 @@ from bw2data.errors import BW2Exception from qtpy import QtWidgets -from activity_browser import app, actions +from activity_browser import app, app def test_activity_delete(monkeypatch, basic_database): @@ -16,7 +16,7 @@ def test_activity_delete(monkeypatch, basic_database): process = basic_database.get("process") - actions.ActivityDelete.run([process.key]) + app.actions.ActivityDelete.run([process.key]) assert len(basic_database) == 1 # removed process and products @@ -28,7 +28,7 @@ def test_activity_duplicate(basic_database): assert len(basic_database) == 4 process = basic_database.get("process") - actions.ActivityDuplicate.run([process.key]) + app.actions.ActivityDuplicate.run([process.key]) assert len(basic_database) == 7 @@ -42,7 +42,7 @@ def test_activity_duplicate(basic_database): # assert get_activity(key) # assert key not in panel.tabs # -# actions.ActivityGraph.run([key]) +# app.actions.ActivityGraph.run([key]) # # assert key in panel.tabs # @@ -62,7 +62,7 @@ def test_activity_new(monkeypatch, basic_database): assert len(basic_database) == 4 - actions.ActivityNewProcess.run(basic_database.name) + app.actions.ActivityNewProcess.run(basic_database.name) assert len(basic_database) == 6 assert len([p for p in basic_database if p["name"] == "new_process"]) == 2 @@ -72,7 +72,7 @@ def test_activity_new(monkeypatch, basic_database): def test_process_open(basic_database): process = basic_database.get("process") - actions.ActivityOpen.run([process.key]) + app.actions.ActivityOpen.run([process.key]) group = app.main_window.centralWidget().groups["Activity Details"] assert "activity_details_basic_process" in [group.widget(i).objectName() for i in range(group.count())] @@ -81,7 +81,7 @@ def test_process_open(basic_database): # def test_product_open(application_instance, basic_database): # product = basic_database.get("product_1") # -# actions.ActivityOpen.run([product.key]) +# app.actions.ActivityOpen.run([product.key]) # # group = application_instance.main_window.centralWidget().groups["Activity Details"] # assert "activity_details_basic_process" in [group.widget(i).objectName() for i in range(group.count())] @@ -104,6 +104,6 @@ def test_process_open(basic_database): # assert projects.current == "default" # assert list(get_activity(key).exchanges())[1].input.key == from_key # -# actions.ActivityRelink.run([key]) +# app.actions.ActivityRelink.run([key]) # # assert list(get_activity(key).exchanges())[1].input.key == to_key diff --git a/tests/actions/test_calculation_setup_actions.py b/tests/actions/test_calculation_setup_actions.py index 0b761a6e2..2b2eba52c 100644 --- a/tests/actions/test_calculation_setup_actions.py +++ b/tests/actions/test_calculation_setup_actions.py @@ -1,7 +1,7 @@ import bw2data as bd from qtpy import QtWidgets -from activity_browser import actions +from activity_browser import app @@ -18,7 +18,7 @@ def test_cs_delete(monkeypatch, basic_database): assert cs_name in bd.calculation_setups - actions.CSDelete.run(cs_name) + app.actions.CSDelete.run(cs_name) assert cs_name not in bd.calculation_setups @@ -36,7 +36,7 @@ def test_cs_duplicate(monkeypatch, basic_database): assert cs_name in bd.calculation_setups assert duplicated not in bd.calculation_setups - actions.CSDuplicate.run(cs_name) + app.actions.CSDuplicate.run(cs_name) assert cs_name in bd.calculation_setups assert duplicated in bd.calculation_setups @@ -53,7 +53,7 @@ def test_cs_new(monkeypatch, basic_database): assert new_cs not in bd.calculation_setups - actions.CSNew.run() + app.actions.CSNew.run() assert new_cs in bd.calculation_setups @@ -71,7 +71,7 @@ def test_cs_rename(monkeypatch, basic_database): assert cs_name in bd.calculation_setups assert renamed_cs not in bd.calculation_setups - actions.CSRename.run(cs_name) + app.actions.CSRename.run(cs_name) assert cs_name not in bd.calculation_setups assert renamed_cs in bd.calculation_setups diff --git a/tests/actions/test_database_actions.py b/tests/actions/test_database_actions.py index a29425787..5882d4178 100644 --- a/tests/actions/test_database_actions.py +++ b/tests/actions/test_database_actions.py @@ -1,7 +1,7 @@ import bw2data as bd from qtpy import QtWidgets -from activity_browser import actions, app +from activity_browser import app, app def test_database_delete(monkeypatch, basic_database): @@ -11,13 +11,13 @@ def test_database_delete(monkeypatch, basic_database): staticmethod(lambda *args, **kwargs: QtWidgets.QMessageBox.Yes), ) - actions.DatabaseDelete.run([basic_database.name]) + app.actions.DatabaseDelete.run([basic_database.name]) assert basic_database.name not in bd.databases def test_database_duplicate(monkeypatch, qtbot, basic_database): - from activity_browser.actions.database.database_duplicate import NewDatabaseDialog, DuplicateDatabaseDialog + from activity_browser.app.actions.database.database_duplicate import NewDatabaseDialog, DuplicateDatabaseDialog dup_db = "db_that_is_duplicated" @@ -29,7 +29,7 @@ def test_database_duplicate(monkeypatch, qtbot, basic_database): assert dup_db not in bd.databases - actions.DatabaseDuplicate.run(basic_database.name) + app.actions.DatabaseDuplicate.run(basic_database.name) dialog = app.main_window.findChild(DuplicateDatabaseDialog) with qtbot.waitSignal(dialog.dup_thread.finished, timeout=60 * 1000): @@ -41,7 +41,7 @@ def test_database_duplicate(monkeypatch, qtbot, basic_database): def test_database_export_excel(monkeypatch, qtbot, basic_database, tmp_path): """Test exporting a database to Excel format.""" - from activity_browser.actions.database.database_export_excel import ExportExcelSetup + from activity_browser.app.actions.database.database_export_excel import ExportExcelSetup # Mock the file dialog to return a path test_path = str(tmp_path / "test_export.xlsx") @@ -52,7 +52,7 @@ def test_database_export_excel(monkeypatch, qtbot, basic_database, tmp_path): ) # Call the action - actions.DatabaseExportExcel.run([basic_database.name]) + app.actions.DatabaseExportExcel.run([basic_database.name]) # Find the wizard dialog and wait for the export thread to finish wizard = app.main_window.findChild(ExportExcelSetup) @@ -69,7 +69,7 @@ def test_database_export_excel(monkeypatch, qtbot, basic_database, tmp_path): def test_database_export_bw2package(monkeypatch, qtbot, basic_database, tmp_path): """Test exporting a database to BW2Package format.""" - from activity_browser.actions.database.database_export_bw2package import ExportBW2PackageSetup + from activity_browser.app.actions.database.database_export_bw2package import ExportBW2PackageSetup # Mock the file dialog to return a path test_path = str(tmp_path / "test_export.bw2package") @@ -80,7 +80,7 @@ def test_database_export_bw2package(monkeypatch, qtbot, basic_database, tmp_path ) # Call the action - actions.DatabaseExportBW2Package.run([basic_database.name]) + app.actions.DatabaseExportBW2Package.run([basic_database.name]) # Find the wizard dialog and wait for the export thread to finish wizard = app.main_window.findChild(ExportBW2PackageSetup) @@ -96,7 +96,7 @@ def test_database_export_bw2package(monkeypatch, qtbot, basic_database, tmp_path def test_database_new(monkeypatch, basic_database): - from activity_browser.actions.database.database_new import NewDatabaseDialog + from activity_browser.app.actions.database.database_new import NewDatabaseDialog new_db = "db_that_is_new" @@ -112,20 +112,20 @@ def test_database_new(monkeypatch, basic_database): assert new_db not in bd.databases - actions.DatabaseNew.run() + app.actions.DatabaseNew.run() assert new_db in bd.databases db_number = len(bd.databases) - actions.DatabaseNew.run() + app.actions.DatabaseNew.run() assert db_number == len(bd.databases) def test_database_delete_multiple(monkeypatch, basic_database): """Test that multiple databases can be deleted at once.""" - from activity_browser.actions.database.database_new import NewDatabaseDialog + from activity_browser.app.actions.database.database_new import NewDatabaseDialog # Create two additional databases db2 = "test_db_2" @@ -140,7 +140,7 @@ def test_database_delete_multiple(monkeypatch, basic_database): monkeypatch.setattr( QtWidgets.QMessageBox, "information", staticmethod(lambda *args, **kwargs: True) ) - actions.DatabaseNew.run() + app.actions.DatabaseNew.run() assert db2 in bd.databases assert db3 in bd.databases @@ -153,7 +153,7 @@ def test_database_delete_multiple(monkeypatch, basic_database): ) # Delete both databases at once - actions.DatabaseDelete.run([db2, db3]) + app.actions.DatabaseDelete.run([db2, db3]) assert db2 not in bd.databases assert db3 not in bd.databases @@ -180,7 +180,7 @@ def test_database_delete_multiple(monkeypatch, basic_database): # assert from_db in Database(db).find_dependents() # assert to_db not in Database(db).find_dependents() # -# actions.DatabaseRelink.run(db) +# app.actions.DatabaseRelink.run(db) # # assert db in databases # assert from_db in databases diff --git a/tests/actions/test_exchange_actions.py b/tests/actions/test_exchange_actions.py index d8511cb72..341b0ae17 100644 --- a/tests/actions/test_exchange_actions.py +++ b/tests/actions/test_exchange_actions.py @@ -1,7 +1,7 @@ import pytest from stats_arrays.distributions import NoUncertainty, UndefinedUncertainty, UniformUncertainty -from activity_browser import actions +from activity_browser import app from activity_browser.ui.dialogs import UncertaintyDialog @@ -26,7 +26,7 @@ # assert len(exchange) == 1 # assert clipboard.text() == "FAILED" # -# actions.ExchangeCopySDF.run(exchange) +# app.actions.ExchangeCopySDF.run(exchange) # # assert clipboard.text() != "FAILED" # @@ -46,7 +46,7 @@ def test_exchange_delete(basic_database): assert len(exchange) == 1 num_exchanges = len(process.exchanges()) - actions.ExchangeDelete.run(exchange) + app.actions.ExchangeDelete.run(exchange) assert len(process.exchanges()) == num_exchanges - 1 @@ -64,7 +64,7 @@ def test_exchange_formula_remove(basic_database): assert len(exchange) == 1 assert exchange[0].as_dict().get("formula") == "5+5" - actions.ExchangeFormulaRemove.run(exchange) + app.actions.ExchangeFormulaRemove.run(exchange) with pytest.raises(KeyError): assert exchange[0].as_dict()["formula"] @@ -85,7 +85,7 @@ def test_exchange_modify(basic_database): assert len(exchange) == 1 assert exchange[0].amount == 10.0 - actions.ExchangeModify.run(exchange[0], new_data) + app.actions.ExchangeModify.run(exchange[0], new_data) assert exchange[0].amount == 200.0 @@ -102,7 +102,7 @@ def test_exchange_new(basic_database): if exchange.input == other ] - actions.ExchangeNew.run([other.key], process.key, "technosphere") + app.actions.ExchangeNew.run([other.key], process.key, "technosphere") assert ( len( @@ -148,7 +148,7 @@ def test_exchange_uncertainty_modify(monkeypatch, basic_database): lambda *args, **kwargs: (True, mock_uncertainty), ) - actions.ExchangeUncertaintyModify.run(exchange) + app.actions.ExchangeUncertaintyModify.run(exchange) # Verify the exchange was updated with the new uncertainty values assert exchange[0].uncertainty_type == UniformUncertainty @@ -170,6 +170,6 @@ def test_exchange_uncertainty_remove(basic_database): assert exchange[0].uncertainty_type == NoUncertainty - actions.ExchangeUncertaintyRemove.run(exchange) + app.actions.ExchangeUncertaintyRemove.run(exchange) assert exchange[0].uncertainty_type == UndefinedUncertainty diff --git a/tests/actions/test_method_actions.py b/tests/actions/test_method_actions.py index 22616422e..81e7921f7 100644 --- a/tests/actions/test_method_actions.py +++ b/tests/actions/test_method_actions.py @@ -7,7 +7,7 @@ UndefinedUncertainty, ) -from activity_browser import actions +from activity_browser import app def test_cf_amount_modify(basic_database): @@ -19,7 +19,7 @@ def test_cf_amount_modify(basic_database): assert len(cf) == 1 assert cf[0][1] == 1.0 or cf[0][1]["amount"] == 1.0 - actions.CFAmountModify.run(method, elementary.id, 200) + app.actions.CFAmountModify.run(method, elementary.id, 200) cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] assert cf[0][1] == 200.0 or cf[0][1]["amount"] == 200.0 @@ -34,7 +34,7 @@ def test_cf_new(basic_database): cf = [cf for cf in Method(method).load() if cf[0] == new_elementary.id] assert len(cf) == 0 - actions.CFNew.run(method, [new_elementary.key]) + app.actions.CFNew.run(method, [new_elementary.key]) cf = [cf for cf in Method(method).load() if cf[0] == new_elementary.id] @@ -55,7 +55,7 @@ def test_cf_remove(monkeypatch, basic_database): assert len(cf) == 1 - actions.CFRemove.run(method, cf) + app.actions.CFRemove.run(method, cf) cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] assert len(cf) == 0 @@ -82,14 +82,14 @@ def test_cf_remove(monkeypatch, basic_database): # assert len(cf) == 1 # assert cf[0][1].get("uncertainty type") == NoUncertainty.id # -# actions.CFUncertaintyModify.run(method, cf) +# app.actions.CFUncertaintyModify.run(method, cf) # # wizard = application_instance.main_window.findChild(UncertaintyWizard) # # assert wizard.isVisible() # # wizard.destroy() -# actions.CFUncertaintyModify.wizard_done(method, new_cf_tuple, uncertainty) +# app.actions.CFUncertaintyModify.wizard_done(method, new_cf_tuple, uncertainty) # # cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] # @@ -105,7 +105,7 @@ def test_cf_uncertainty_remove(basic_database): assert cf[0][1].get("uncertainty type") == NoUncertainty.id - actions.CFUncertaintyRemove.run(method, cf) + app.actions.CFUncertaintyRemove.run(method, cf) cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] assert ( @@ -124,13 +124,13 @@ def test_method_delete(monkeypatch, basic_database): assert method in methods - actions.MethodDelete.run([method]) + app.actions.MethodDelete.run([method]) assert method not in methods def test_method_duplicate(monkeypatch, basic_database): - from activity_browser.actions.method.method_duplicate import TupleNameDialog + from activity_browser.app.actions.method.method_duplicate import TupleNameDialog method = ("basic_method",) duplicated_method = ("basic_method - Copy",) @@ -146,7 +146,7 @@ def test_method_duplicate(monkeypatch, basic_database): assert method in methods assert duplicated_method not in methods - actions.MethodDuplicate.run([method], "leaf") + app.actions.MethodDuplicate.run([method], "leaf") assert method in methods assert duplicated_method in methods @@ -172,7 +172,7 @@ def test_method_new(monkeypatch, basic_database): assert new_method not in methods - actions.MethodNew.run() + app.actions.MethodNew.run() assert new_method in methods @@ -202,7 +202,7 @@ def test_calculation_setups_updated_on_method_delete(monkeypatch, basic_database staticmethod(lambda *args, **kwargs: QtWidgets.QMessageBox.Yes), ) - actions.MethodDelete.run([method]) + app.actions.MethodDelete.run([method]) # method removed assert method not in bw_methods @@ -236,7 +236,7 @@ def test_calculation_setups_updated_on_method_rename(monkeypatch, basic_database staticmethod(lambda *args, **kwargs: new), ) - actions.MethodRename.run(old) + app.actions.MethodRename.run(old) # setups reference the new method name cs = bd.calculation_setups["basic_calculation_setup"] From 256fed269d9b605eee34253d7ac90f696fb3e9c4 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 7 Nov 2025 09:58:33 +0100 Subject: [PATCH 097/267] commenting out unused signals to prepare for deletion --- .../calculation_setup/scenario_section.py | 5 -- activity_browser/app/signalling.py | 52 +++++++++---------- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/activity_browser/app/pages/calculation_setup/scenario_section.py b/activity_browser/app/pages/calculation_setup/scenario_section.py index 7260aff44..f7570590c 100644 --- a/activity_browser/app/pages/calculation_setup/scenario_section.py +++ b/activity_browser/app/pages/calculation_setup/scenario_section.py @@ -90,7 +90,6 @@ def __init__(self, parent=None): def connect_signals(self) -> None: app.signals.project.changed.connect(self.clear_tables) app.signals.project.changed.connect(self.can_add_table) - app.signals.parameter_superstructure_built.connect(self.handle_superstructure_signal) self.table_btn.clicked.connect(self.add_table) self.table_btn.clicked.connect(self.can_add_table) @@ -213,10 +212,6 @@ def can_add_table(self) -> None: """ self.table_btn.setEnabled(len(self.tables) < self.max_tables) - def handle_superstructure_signal(self, table_idx: int, df: pd.DataFrame) -> None: - table = self.tables[table_idx] - table.sync_superstructure(df) - def save_action(self) -> None: """Creates and saves to file (.xlsx, or .csv) the scenario dataframe after the loaded scenarios have been merged. Will not contain duplicates. Will not contain self-referential technosphere flows. diff --git a/activity_browser/app/signalling.py b/activity_browser/app/signalling.py index 0e5081e94..9bd55aed6 100644 --- a/activity_browser/app/signalling.py +++ b/activity_browser/app/signalling.py @@ -92,35 +92,35 @@ class ABSignals(QObject): metadata = MetaDataSignals() parameter = ParameterSignals() - import_project = Signal() # Import a project - export_project = Signal() # Export the current project + # import_project = Signal() # Import a project + # export_project = Signal() # Export the current project database_selected = Signal(str) # This database was selected (opened) | name of database database_read_only_changed = Signal(str, bool) # The read_only state of database changed | name of database, read-only state - database_tab_open = Signal(str) # This database tab is being viewed by user | name of database - add_activity_to_history = Signal(tuple) - safe_open_activity_tab = Signal(tuple) # Open activity details tab in read-only mode | key of activity - unsafe_open_activity_tab = Signal(tuple) # Open activity details tab in editable mode | key of activity - close_activity_tab = Signal(tuple) # Close this activity details tab | key of activity - open_activity_graph_tab = Signal(tuple) # Open the graph-view tab | key of activity - edit_activity = Signal(str) # An activity in this database may now be edited | name of database - added_parameter = Signal(str, str, str) # This parameter has been added | name of the parameter, amount, type (project, database or activity) - parameters_changed = Signal() # The parameters have changed - parameter_scenario_sync = Signal(int, object, bool) # Synchronize this data for table | index of the table, dataframe with scenario data, include default scenario - parameter_superstructure_built = Signal(int, object) # Superstructure built from scenarios | index of the table, dataframe with scenario data - set_default_calculation_setup = Signal() # Show the default (first) calculation setup - calculation_setup_changed = Signal() # Calculation setup was changed - calculation_setup_selected = Signal(str) # This calculation setup was selected (opened) | name of calculation setup - lca_calculation = Signal(dict) # Generate a calculation setup | dictionary with name, type (simple/scenario) and potentially scenario data - delete_method = Signal(tuple, str) # Delete this method | tuple of impact category, level of tree OR the proxy - method_selected = Signal(tuple) # This method was selected (opened) | tuple of method + # database_tab_open = Signal(str) # This database tab is being viewed by user | name of database + # add_activity_to_history = Signal(tuple) + # safe_open_activity_tab = Signal(tuple) # Open activity details tab in read-only mode | key of activity + # unsafe_open_activity_tab = Signal(tuple) # Open activity details tab in editable mode | key of activity + # close_activity_tab = Signal(tuple) # Close this activity details tab | key of activity + # open_activity_graph_tab = Signal(tuple) # Open the graph-view tab | key of activity + # edit_activity = Signal(str) # An activity in this database may now be edited | name of database + # added_parameter = Signal(str, str, str) # This parameter has been added | name of the parameter, amount, type (project, database or activity) + # parameters_changed = Signal() # The parameters have changed + # parameter_scenario_sync = Signal(int, object, bool) # Synchronize this data for table | index of the table, dataframe with scenario data, include default scenario + # parameter_superstructure_built = Signal(int, object) # Superstructure built from scenarios | index of the table, dataframe with scenario data + # set_default_calculation_setup = Signal() # Show the default (first) calculation setup + # calculation_setup_changed = Signal() # Calculation setup was changed + # calculation_setup_selected = Signal(str) # This calculation setup was selected (opened) | name of calculation setup + # lca_calculation = Signal(dict) # Generate a calculation setup | dictionary with name, type (simple/scenario) and potentially scenario data + # delete_method = Signal(tuple, str) # Delete this method | tuple of impact category, level of tree OR the proxy + # method_selected = Signal(tuple) # This method was selected (opened) | tuple of method monte_carlo_finished = Signal() # The monte carlo calculations are finished - new_statusbar_message = Signal(str) # Update the statusbar this message | message - restore_cursor = Signal() # Restore the cursor to normal - project_updates_available = Signal(str, int) # Project name and number of updates available - toggle_show_or_hide_tab = Signal(str) # Show/Hide the tab with this name | name of tab - show_tab = Signal(str) # Show this tab | name of tab - hide_tab = Signal(str) # Hide this tab | name of tab - hide_when_empty = Signal() # Show/Hide tab when it has/does not have sub-tabs + # new_statusbar_message = Signal(str) # Update the statusbar this message | message + # restore_cursor = Signal() # Restore the cursor to normal + # project_updates_available = Signal(str, int) # Project name and number of updates available + # toggle_show_or_hide_tab = Signal(str) # Show/Hide the tab with this name | name of tab + # show_tab = Signal(str) # Show this tab | name of tab + # hide_tab = Signal(str) # Hide this tab | name of tab + # hide_when_empty = Signal() # Show/Hide tab when it has/does not have sub-tabs plugin_selected = Signal(str, bool) # This plugin was/was not selected | name of plugin, selected state def __getattribute__(self, item): From d424a502aa92d84ae3315721a68b676d070efcdf Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 7 Nov 2025 16:46:49 +0100 Subject: [PATCH 098/267] Fixing the new model and view framework for the exchanges tab --- .../pages/activity_details/consumers_tab.py | 59 +-- .../app/pages/activity_details/data_tab.py | 115 +++--- .../pages/activity_details/exchanges_tab.py | 375 ++++++++---------- .../calculation_setup/scenario_section.py | 2 - activity_browser/bwutils/metadata/metadata.py | 2 +- activity_browser/ui/core/tree_model.py | 15 +- activity_browser/ui/delegates/new_formula.py | 7 + activity_browser/ui/delegates/uncertainty.py | 37 +- activity_browser/ui/widgets/central.py | 10 +- activity_browser/ui/widgets/menu.py | 2 +- 10 files changed, 308 insertions(+), 316 deletions(-) diff --git a/activity_browser/app/pages/activity_details/consumers_tab.py b/activity_browser/app/pages/activity_details/consumers_tab.py index 4260c97c5..92e10b28d 100644 --- a/activity_browser/app/pages/activity_details/consumers_tab.py +++ b/activity_browser/app/pages/activity_details/consumers_tab.py @@ -6,7 +6,7 @@ from activity_browser import app, app from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.ui import widgets, icons +from activity_browser.ui import widgets, icons, core class ConsumersTab(QtWidgets.QWidget): @@ -31,7 +31,7 @@ def __init__(self, activity: tuple | int | bd.Node, parent=None): self.activity = refresh_node(activity) self.view = ConsumersView(self) - self.model = ConsumersModel(self) + self.model = ConsumersModel(parent=self) self.view.setModel(self.model) self.view.setSortingEnabled(True) @@ -59,7 +59,9 @@ def sync(self): else: exchanges = list(self.activity.upstream()) - self.model.setDataFrame(self.build_df(exchanges)) + df = self.build_df(exchanges) + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) def build_df(self, exchanges: list[bd.Edge]) -> pd.DataFrame: """ @@ -95,7 +97,7 @@ def build_df(self, exchanges: list[bd.Edge]) -> pd.DataFrame: return df[cols] -class ConsumersView(widgets.ABTreeView): +class ConsumersView(widgets.ABNewTreeView): """ A view that displays the consumers in a tree structure. """ @@ -106,34 +108,43 @@ def mouseDoubleClickEvent(self, event) -> None: Args: event: The mouse event. """ - items = [i.internalPointer() for i in self.selectedIndexes() if isinstance(i.internalPointer(), ConsumersItem)] - keys = list({i["_consumer_key"] for i in items}) + indexes = self.selectedIndexes() + if not indexes: + return super().mouseDoubleClickEvent(event) + + keys = self.model().values_from_indices("_consumer_key", indexes) if keys: app.actions.ActivityOpen.run(keys) -class ConsumersItem(widgets.ABDataItem): +class ConsumersModel(core.ABTreeModel): """ - An item representing a consumer in the tree view. + A model representing the data for the consumers. """ - def decorationData(self, col, key): + + def decorationData(self, index): """ - Provides decoration data for the item. + Provides decoration data for the model. Args: - col: The column index. - key: The key for which to provide decoration data. + index: The index for which to provide decoration data. Returns: - The decoration data for the item. + The decoration data for the model. """ - if key not in ["product", "consumer"]: - return + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return None + + if column_name not in ["product", "consumer"]: + return None - if key == "product": - activity_type = self["_product_type"] - else: # key is "consumer" - activity_type = self["_consumer_type"] + if column_name == "product": + activity_type = row.get("_product_type") + else: # column_name == "consumer" + activity_type = row.get("_consumer_type") if activity_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: return icons.qicons.biosphere @@ -146,12 +157,4 @@ def decorationData(self, col, key): if activity_type == "waste": return icons.qicons.waste - -class ConsumersModel(widgets.ABItemModel): - """ - A model representing the data for the consumers. - - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = ConsumersItem + return None diff --git a/activity_browser/app/pages/activity_details/data_tab.py b/activity_browser/app/pages/activity_details/data_tab.py index 48ab6c3e9..b7d94464f 100644 --- a/activity_browser/app/pages/activity_details/data_tab.py +++ b/activity_browser/app/pages/activity_details/data_tab.py @@ -6,7 +6,7 @@ from activity_browser import app from activity_browser.bwutils.commontasks import refresh_node, database_is_locked -from activity_browser.ui import widgets, delegates +from activity_browser.ui import widgets, delegates, core class DataTab(QtWidgets.QWidget): @@ -32,12 +32,14 @@ def __init__(self, activity: tuple | int | bd.Node, parent=None): # Data TreeView self.data_view = DataView(self) - self.data_model = DataModel(self) + self.data_model = DataModel(parent=self) self.data_view.setModel(self.data_model) - self.data_model.setDataFrame(self.build_df()) - self.data_model.group(2) - self.data_view.setColumnHidden(2, True) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.data_model.set_dataframe(df) + self.data_model.group(["name"]) + self.data_view.setColumnHidden(1, True) self.data_view.expandAll() self.build_layout() @@ -55,7 +57,12 @@ def sync(self) -> None: Synchronizes the widget with the current state of the activity. """ self.activity = refresh_node(self.activity) - self.data_model.setDataFrame(self.build_df()) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.data_model.set_dataframe(df) + self.data_model.group(["name"]) + self.data_view.setColumnHidden(1, True) + self.data_view.expandAll() def build_df(self) -> pd.DataFrame: """ @@ -85,7 +92,7 @@ def build_df(self) -> pd.DataFrame: return df[cols] -class DataView(widgets.ABTreeView): +class DataView(widgets.ABNewTreeView): """ A view that displays the data in a tree structure. @@ -93,75 +100,87 @@ class DataView(widgets.ABTreeView): defaultColumnDelegates (dict): The default column delegates for the view. """ defaultColumnDelegates = { - "key": delegates.StringDelegate, + "field": delegates.StringDelegate, "value": delegates.NewFormulaDelegate, } -class DataItem(widgets.ABDataItem): +class DataModel(core.ABTreeModel): """ - An item representing a data entry in the tree view. + A model representing the data for the activity. """ - def flags(self, col: int, key: str): + + def setData(self, index: QtCore.QModelIndex, value, role: int = QtCore.Qt.ItemDataRole.EditRole) -> bool: """ - Returns the item flags for the given column and key. + Sets the data for the given index. Args: - col (int): The column index. - key (str): The key for which to return the flags. + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. Returns: - QtCore.Qt.ItemFlags: The item flags. + bool: True if the data was set successfully, False otherwise. """ - flags = super().flags(col, key) + if role != QtCore.Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False - if key == "value" and not database_is_locked(self["_activity_db"]): - return flags | QtCore.Qt.ItemFlag.ItemIsEditable - return flags + if column_name == "value": + value = eval(value) + app.actions.ActivityModify.run(row.get("_activity_id"), row.get("field"), value) + return True - def displayData(self, col: int, key: str): + return False + + def indexEditable(self, index: QtCore.QModelIndex) -> bool: """ - Returns the display data for the given column and key. + Returns whether the index is editable. Args: - col (int): The column index. - key (str): The key for which to return the display data. + index (QtCore.QModelIndex): The index to check. Returns: - str: The display data. + bool: True if the index is editable, False otherwise. """ - if key == "value": - data = self[key] - if isinstance(data, str): - return f"'{data}'" - return str(data) + column_name = self.column_name(index) + row = self.row(index) - return super().displayData(col, key) + if row is None: + return False - def setData(self, col: int, key: str, value) -> bool: + if column_name == "value" and not database_is_locked(row.get("_activity_db")): + return True + + return False + + def displayData(self, index: QtCore.QModelIndex) -> any: """ - Sets the data for the given column and key. + Provides display data for the model. Args: - col (int): The column index. - key (str): The key for which to set the data. - value: The value to set. + index (QtCore.QModelIndex): The index for which to provide display data. Returns: - bool: True if the data was set successfully, False otherwise. + The display data for the index. """ - if key in ["value"]: - value = eval(value) - app.actions.ActivityModify.run(self["_activity_id"], self["field"], value) - - return False + column_name = self.column_name(index) + row = self.row(index) + if row is None: + # Branch node + path = index.internalPointer() + return path[-1] if index.column() == 0 else None -class DataModel(widgets.ABItemModel): - """ - A model representing the data for the activity. + if column_name == "value": + data = row.get(column_name) + if isinstance(data, str): + return f"'{data}'" + return str(data) - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = DataItem + return row.get(column_name) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index fda673d92..7cb9d103d 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -1,6 +1,6 @@ from loguru import logger -from qtpy import QtWidgets, QtGui +from qtpy import QtWidgets, QtGui, QtCore from qtpy.QtCore import Qt import pandas as pd @@ -10,7 +10,7 @@ from activity_browser import app, app from activity_browser.bwutils.commontasks import refresh_node, database_is_locked, database_is_legacy, is_node_product, is_node_biosphere, parameters_in_scope -from activity_browser.ui import widgets, icons, delegates +from activity_browser.ui import widgets, icons, delegates, core @@ -48,7 +48,7 @@ def __init__(self, activity: tuple | int | bd.Node, parent=None): # Output Table self.output_view = ExchangesView(self) - self.output_model = ExchangesModel(self) + self.output_model = ExchangesModel(tab=self) self.output_view.setModel(self.output_model) # Set indentation for output view @@ -56,7 +56,7 @@ def __init__(self, activity: tuple | int | bd.Node, parent=None): # Input Table self.input_view = ExchangesView(self) - self.input_model = ExchangesModel(self) + self.input_model = ExchangesModel(tab=self) self.input_view.setModel(self.input_model) # Set indentation for input view @@ -114,11 +114,13 @@ def sync(self) -> None: # Update the models with the new data output_df = self.build_df(outputs) - self.output_model.setDataFrame(output_df) + output_df.reset_index(drop=True, inplace=True) + self.output_model.set_dataframe(output_df) self.output_view.drag_drop_hint.setVisible(output_df.empty) input_df = self.build_df(inputs) - self.input_model.setDataFrame(input_df) + input_df.reset_index(drop=True, inplace=True) + self.input_model.set_dataframe(input_df) self.input_view.drag_drop_hint.setVisible(input_df.empty) def build_df(self, exchanges) -> pd.DataFrame: @@ -133,12 +135,13 @@ def build_df(self, exchanges) -> pd.DataFrame: """ # Define the columns for the metadata cols = ["key", "unit", "name", "product", "location", "database", "allocation_factor", - "properties", "processor", "categories"] + "properties", "processor", "categories", "type"] # Create a DataFrame from the exchanges - exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "formula", "uncertainty type", "comment"]) + exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "formula", "comment"]) exc_df["type"] = [x["type"] for x in exchanges] - act_df = app.metadata.get_metadata(exc_df["input"].unique(), cols) + exc_df["uncertainty"] = [x.uncertainty for x in exchanges] + act_df = app.metadata.get_metadata(exc_df["input"].unique(), cols).rename(columns={"type": "_producer_type"}) # Merge the exchanges DataFrame with the metadata DataFrame df = exc_df.merge( @@ -170,7 +173,7 @@ def build_df(self, exchanges) -> pd.DataFrame: df.rename({ "input": "_input_key", "processor": "_processor_key", - "uncertainty type": "uncertainty", + "type": "_exchange_type", "name": "producer", }, axis="columns", inplace=True) @@ -241,58 +244,68 @@ def get_exchange_type(activity_key: tuple) -> str | None: class RelinkDelegate(delegates.StringDelegate): matched: pd.DataFrame - column: str - item: "ExchangesItem" def createEditor(self, parent, option, index): - self.item = index.internalPointer() - self.column = index.model().columns()[index.column()] - self.column = "name" if self.column == "producer" else self.column + model: ExchangesModel = index.model() + + column = model.column_name(index) + column = "name" if column == "producer" else column - if self.column == "product" and self.item.functional: + if column == "product" and model.functional(index): return super().createEditor(parent, option, index) + + row = model.row(index) setup = { - "database": self.item["database"], - "name": self.item["producer"], - "product": self.item["product"], - "categories": self.item["categories"], - "location": self.item["location"], - "type": self.item.exchange.input["type"], + "database": row["database"], + "name": row["producer"], + "product": row["product"], + "categories": row["categories"], + "location": row["location"], + "type": row["_producer_type"], } - del setup[self.column] + del setup[column] # remove the column being edited because we are looking for alternatives self.matched = app.metadata.match(**setup) combo = QtWidgets.QComboBox(parent) - combo.addItems(list(self.matched.get(self.column, []).astype(str))) + combo.addItems(list(self.matched.get(column, []).astype(str))) return combo def setEditorData(self, editor: QtWidgets.QComboBox, index): - if self.column == "product" and self.item.functional: + model: ExchangesModel = index.model() + column = model.column_name(index) + column = "name" if column == "producer" else column + + if column == "product" and model.functional(index): return super().setEditorData(editor, index) - value = index.model().data(index, 0) + value = index.data() if value: i = editor.findText(str(value)) if i >= 0: editor.setCurrentIndex(i) def setModelData(self, editor: QtWidgets.QComboBox, model, index): - if self.column == "product" and self.item.functional: + model: ExchangesModel = index.model() + column = model.column_name(index) + column = "name" if column == "producer" else column + + if column == "product" and model.functional(index): return super().setModelData(editor, model, index) choice = editor.currentIndex() key = self.matched.iloc[choice].key + row = model.row(index) app.actions.ExchangeModify.run( - index.internalPointer().exchange, + row.get("_exchange"), {"input": key} ) -class ExchangesView(widgets.ABTreeView): +class ExchangesView(widgets.ABNewTreeView): """ A view that displays the exchanges in a tree structure. @@ -469,235 +482,181 @@ def setDefaultColumnDelegates(self): self.setItemDelegateForColumn(i, self.propertyDelegate) -class ExchangesItem(widgets.ABDataItem): +class ExchangesModel(core.ABTreeModel): """ - An item representing an exchange in the tree view. - - Attributes: - background_color (str): The background color of the item. + A model representing the data for the exchanges. """ - background_color = None - - @property - def exchange(self): + def __init__(self, tab: ExchangesTab): + super().__init__(parent=tab) + self.tab = tab + + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: """ - Returns the exchange associated with the item. + Sets the data for the given index. - Returns: - The exchange associated with the item. - """ - return self["_exchange"] - - @property - def functional(self): - """ - Returns whether the exchange is functional. + Args: + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. Returns: - bool: True if the exchange is functional, False otherwise. + bool: True if the data was set successfully, False otherwise. """ - return self["_exchange"].get("type") == "production" + if role != Qt.ItemDataRole.EditRole: + return False - @property - def scoped_parameters(self): - """ - Returns the parameters in scope of the current exchange. + column_name = self.column_name(index) + row = self.row(index) - Returns: - dict: The parameters in scope. - """ - return parameters_in_scope(self["_exchange"].output) + if row is None: + return False - def flags(self, col: int, key: str): - """ - Returns the item flags for the given column and key. + exchange = row.get("_exchange") + if exchange is None: + return False - Args: - col (int): The column index. - key (str): The key for which to return the flags. + if column_name in ["amount", "formula", "comment"]: + if column_name == "formula" and not str(value).strip(): + app.actions.ExchangeFormulaRemove.run([exchange]) + return True - Returns: - QtCore.Qt.ItemFlags: The item flags. - """ - flags = super().flags(col, key) - # Check if the database is read-only. If it is, return the default flags. - if database_is_locked(self.exchange.output["database"]): - return flags + app.actions.ExchangeModify.run(exchange, {column_name.lower(): value}) + return True - # Allow editing for specific keys: "amount", "formula", and "uncertainty". - if key in ["amount", "formula", "uncertainty", "comment"]: - return flags | Qt.ItemFlag.ItemIsEditable + if column_name in ["unit", "product", "location", "substitution_factor", "allocation_factor"]: + act = exchange.input + app.actions.ActivityModify.run(act.key, column_name.lower(), value) + return True - # Allow editing for "unit", "name", and "substitution_factor" if the exchange is functional. - if key in ["unit", "product", "substitution_factor"] and self.functional: - return flags | Qt.ItemFlag.ItemIsEditable + if column_name.startswith("property_"): + # should move this process to a separate action + process = exchange.output + product = exchange.input + + if not isinstance(process, bf.Process) or not isinstance(product, bf.Product): + logger.warning(f"Expected a Process and Product, got {type(process)} and {type(product)} instead.") + return False - if key in ["producer", "product", "location", "categories", "database"] and not self.functional: - return flags | Qt.ItemFlag.ItemIsEditable + prop_key = column_name[9:] - # Allow editing for properties (keys starting with "property_") if the exchange is functional. - if key.startswith("property_") and self.functional: - return flags | Qt.ItemFlag.ItemIsEditable + prop = process.property_template(prop_key, value) - # Allow editing for "allocation_factor" if the allocation is manual and the exchange is functional. - if key == "allocation_factor" and self.exchange.output.get("allocation") == "manual" and self.functional: - return flags | Qt.ItemFlag.ItemIsEditable + props = product.get("properties", {}) + props[prop_key] = prop - # Return the default flags if none of the above conditions are met. - return flags + app.actions.ActivityModify.run(product, "properties", props) + return True - def displayData(self, col: int, key: str): + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: """ - Returns the display data for the given column and key. + Provides decoration data for the model. Args: - col (int): The column index. - key (str): The key for which to return the display data. + index (QtCore.QModelIndex): The index for which to provide decoration data. Returns: - str: The display data. + The decoration data for the index. """ - if key in ["allocation_factor", "substitute", "substitution_factor"] and not self.functional: - return None + column_name = self.column_name(index) + row = self.row(index) - if key.startswith("property_") and not self.functional: + if row is None: return None + + if not isinstance(row, pd.Series): + pass + + if column_name in ["product", "producer"]: + activity_type = row.get("_producer_type") + if activity_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: + return icons.qicons.biosphere if column_name == "producer" else None + if activity_type == "processwithreferenceproduct": + return icons.qicons.processproduct if column_name == "producer" else icons.qicons.product + if activity_type in ["product", "process", "multifunctional", "nonfunctional"]: + return icons.qicons.process if column_name == "producer" else icons.qicons.product + if activity_type == "waste": + return icons.qicons.process if column_name == "producer" else icons.qicons.waste + + if column_name == "amount": + formula = row.get("formula") + if pd.isna(formula) or formula is None or formula == "": + return None + return icons.qicons.parameterized - if key.startswith("property_") and isinstance(self[key], float): - return { - "amount": self[key], - "unit": "undefined", - "normalize": False, - } - - if key.startswith("property_") and self[key].get("normalize", True): - prop = self[key].copy() - prop["unit"] = prop['unit'] + f" / {self['unit']}" - return prop - - return super().displayData(col, key) - - def decorationData(self, col, key): + return None + + def fontData(self, index: QtCore.QModelIndex) -> any: """ - Provides decoration data for the item. + Provides font data for the model. Args: - col: The column index. - key: The key for which to provide decoration data. + index (QtCore.QModelIndex): The index for which to provide font data. Returns: - The decoration data for the item. + QtGui.QFont: The font data for the index. """ - if key not in ["product", "substitute_name", "amount", "producer"] or not self.displayData(col, key): - return - - if key == "amount": - if pd.isna(self["formula"]) or self["formula"] is None: - # empty icon to align the values - return icons.qicons.empty - return icons.qicons.parameterized + if self.functional(index): + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font - activity_type = self.exchange.input.get("type") - - if activity_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: - return icons.qicons.biosphere - if activity_type == "product": - return icons.qicons.product - if activity_type == "processwithreferenceproduct": - return icons.qicons.processproduct - if activity_type == "process": - return icons.qicons.process - if activity_type == "waste": - return icons.qicons.waste return None - def fontData(self, col: int, key: str): - """ - Returns the font data for the given column and key. + def indexEditable(self, index): + column_name = self.column_name(index) + row = self.row(index) + functional = self.functional(index) - Args: - col (int): The column index. - key (str): The key for which to return the font data. + # Prevent editing if the database is locked + if database_is_locked(row["_exchange"]["output"][0]): + return False - Returns: - QtGui.QFont: The font data. - """ - font = super().fontData(col, key) + # Allow editing for specific keys: "amount", "formula", and "uncertainty". + if column_name in ["amount", "formula", "uncertainty", "comment"]: + return True - # set the font to bold if it's a production/functional exchange - if self.functional: - font.setWeight(QtGui.QFont.Weight.DemiBold) - return font + # Allow editing for "unit", "name", and "substitution_factor" if the exchange is functional. + if column_name in ["unit", "product"] and functional: + return True - def backgroundData(self, col: int, key: str): + # Allow editing for "producer", "location", "categories", and "database" if the exchange is not functional. + if column_name in ["producer", "product", "location", "categories", "database"] and not functional: + return True + + # Allow editing for properties (keys starting with "property_") if the exchange is functional. + if column_name.startswith("property_") and functional: + return True + + # Allow editing for allocation_factor if functional and allocation is manual + if column_name == "allocation_factor" and functional and self.tab.activity.get("allocation") == "manual": + return True + + return False + + def functional(self, index): """ - Returns the background data for the given column and key. + Returns whether the index is functional. Args: - col (int): The column index. - key (str): The key for which to return the background data. + index (QtCore.QModelIndex): The index to check. Returns: - QtGui.QBrush: The background brush for the item. + bool: True if the index is functional, False otherwise. """ - if self.background_color: - return QtGui.QBrush(QtGui.QColor(self.background_color)) - - if key == f"property_{self['_allocate_by']}": - from activity_browser.app import application - return application.palette().alternateBase() - - def setData(self, col: int, key: str, value) -> bool: + return self.row(index).get("_exchange_type") == "production" + + def scoped_parameters(self, index): """ - Sets the data for the given column and key. + Returns the scoped parameters for the index. Args: - col (int): The column index. - key (str): The key for which to set the data. - value: The value to set. + index (QtCore.QModelIndex): The index to get scoped parameters for. Returns: - bool: True if the data was set successfully, False otherwise. + list: A list of scoped parameters for the index. """ - if key in ["amount", "formula", "comment"]: - if key == "formula" and not str(value).strip(): - app.actions.ExchangeFormulaRemove.run([self.exchange]) - return True - - app.actions.ExchangeModify.run(self.exchange, {key.lower(): value}) - return True - - if key in ["unit", "product", "location", "substitution_factor", "allocation_factor"]: - act = self.exchange.input - app.actions.ActivityModify.run(act.key, key.lower(), value) - - if key.startswith("property_"): - # should move this process to a separate action - process = self.exchange.output - product = self.exchange.input - - if not isinstance(process, bf.Process) or not isinstance(product, bf.Product): - logger.warning(f"Expected a Process and Product, got {type(process)} and {type(product)} instead.") - return False - - prop_key = key[9:] - - prop = process.property_template(prop_key, value) - - props = product.get("properties", {}) - props[prop_key] = prop - - app.actions.ActivityModify.run(product, "properties", props) - - return False - - -class ExchangesModel(widgets.ABItemModel): - """ - A model representing the data for the exchanges. - - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = ExchangesItem - + row = self.row(index) + return parameters_in_scope(row["_exchange"].output) + \ No newline at end of file diff --git a/activity_browser/app/pages/calculation_setup/scenario_section.py b/activity_browser/app/pages/calculation_setup/scenario_section.py index f7570590c..a578868c7 100644 --- a/activity_browser/app/pages/calculation_setup/scenario_section.py +++ b/activity_browser/app/pages/calculation_setup/scenario_section.py @@ -10,8 +10,6 @@ from activity_browser import app from activity_browser.ui import icons, widgets -from activity_browser.bwutils import errors - diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index d88e43ebf..f3ecf0f75 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -75,7 +75,7 @@ def match(self, **kwargs: dict[str, str]) -> pd.DataFrame: df = self.dataframe.query( " and ".join( [ - f"`{key}` == '{value}'" if not pd.isna(value) else f"`{key}`.isnull()" + f"`{key}`.astype('str') == {str(value)!r}" if not pd.isna(value) else f"`{key}`.isnull()" for key, value in kwargs.items() ]) ) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index ae6e6d1f3..2285727b6 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -35,7 +35,7 @@ def __init__(self, df: pd.DataFrame = None, parent: Optional[QWidget] = None, ch def columns(self) -> list[str]: """Return the list of column names, including the tree column.""" - return ["index"] + list(self.df.columns) + return ["index"] + [col for col in self.df.columns if not col.startswith("_")] def column_name(self, index: QModelIndex) -> str: """Return the name of the column at the given index, including the tree column.""" @@ -52,7 +52,12 @@ def row(self, index: QModelIndex) -> pd.Series | None: if len(path) < self.df.index.nlevels: return None - return self.df.loc[path] + row = self.df.loc[path] + + if not isinstance(row, pd.Series): + pass + + return row # --- required model overrides --- def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex: @@ -104,7 +109,7 @@ def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802 (Qt signature) # Always return the full column count for consistent tree structure - return len(self.df.columns) + 1 # +1 for tree column + return len(self.columns()) #--- data overrides --- def data(self, index: QModelIndex, role: int = Qt.DisplayRole): @@ -137,6 +142,10 @@ def displayData(self, index: QModelIndex) -> any: col_name = self.headerData(index.column()) val = self.df.at[path, col_name] + + if not hasattr(val, "__iter__") and pd.isna(val): + return None + return val def editData(self, index: QModelIndex) -> any: diff --git a/activity_browser/ui/delegates/new_formula.py b/activity_browser/ui/delegates/new_formula.py index db929ff8c..aaa753ce0 100644 --- a/activity_browser/ui/delegates/new_formula.py +++ b/activity_browser/ui/delegates/new_formula.py @@ -1,3 +1,4 @@ +from loguru import logger from qtpy import QtCore, QtWidgets from qtpy.QtGui import QFontMetrics, QFont from qtpy.QtCore import Qt @@ -30,7 +31,10 @@ def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): if hasattr(index.internalPointer(), 'scoped_parameters'): scope = index.internalPointer().scoped_parameters + elif hasattr(index.model(), 'scoped_parameters'): + scope = index.model().scoped_parameters(index) else: + logger.warning("No scope found for formula editor. Define `scoped_parameters` attribute in index model.") scope = {} from activity_browser.ui.widgets import ABFormulaEdit @@ -49,7 +53,10 @@ def createEditor(self, parent, option, index): from activity_browser.ui.widgets import ABFormulaEdit if hasattr(index.internalPointer(), 'scoped_parameters'): scope = index.internalPointer().scoped_parameters + elif hasattr(index.model(), 'scoped_parameters'): + scope = index.model().scoped_parameters(index) else: + logger.warning("No scope found for formula editor. Define `scoped_parameters` attribute in index model.") scope = {} editor = ABFormulaEdit(parent, scope) return editor diff --git a/activity_browser/ui/delegates/uncertainty.py b/activity_browser/ui/delegates/uncertainty.py index 74b5411ae..38130f2b0 100644 --- a/activity_browser/ui/delegates/uncertainty.py +++ b/activity_browser/ui/delegates/uncertainty.py @@ -2,9 +2,7 @@ from qtpy import QtCore, QtWidgets from stats_arrays import uncertainty_choices as uc -from activity_browser import app - -from activity_browser.app import signals +from activity_browser.ui.dialogs import UncertaintyDialog class UncertaintyDelegate(QtWidgets.QStyledItemDelegate): @@ -12,25 +10,22 @@ class UncertaintyDelegate(QtWidgets.QStyledItemDelegate): `setModelData` stores the integer id of the selected uncertainty distribution. """ - - def __init__(self, parent=None): - super().__init__(parent) - uc.check_id_uniqueness() - self.choices = {u.description: u.id for u in uc.choices} - def displayText(self, value, locale): """Take the given integer id and return the description. Will return the 'Unknown' uncertainty description if the given id either cannot be found or the value is 'nan' (when id is not set) """ - try: - return uc[int(value)].description - except (IndexError, ValueError): - return uc[0].description + if isinstance(value, (int, float)) and int(value) in uc.id_dict: + return uc.id_dict[int(value)].description + elif isinstance(value, dict) and value.get("uncertainty type") in uc.id_dict: + return uc[value["uncertainty type"]].description + return uc[0].description def createEditor(self, parent, option, index): """Simply use the wizard for updating uncertainties. Send a signal.""" + from activity_browser import app + item = index.internalPointer() item_name = item.__class__.__name__ @@ -42,17 +37,23 @@ def createEditor(self, parent, option, index): app.actions.CFUncertaintyModify.run( item["_impact_category_name"], [(item["_id"], item["_cf"]),] ) + else: + return UncertaintyDialog(parent=app.main_window, initial=index.data()) + + def setEditorData(self, editor, index: QtCore.QModelIndex): + pass - def setEditorData(self, editor: QtWidgets.QComboBox, index: QtCore.QModelIndex): - """Simply use the wizard for updating uncertainties.""" + def updateEditorGeometry(self, editor, option, index): pass def setModelData( self, - editor: QtWidgets.QComboBox, + editor: UncertaintyDialog, model: QtCore.QAbstractItemModel, index: QtCore.QModelIndex, ): """Read the current text and look up the actual ID of that uncertainty type.""" - uc_id = self.choices.get(editor.currentText(), 0) - model.setData(index, uc_id, QtCore.Qt.EditRole) + if not editor.result() == QtWidgets.QDialog.Accepted: + return + + model.setData(index, editor.result_dict, QtCore.Qt.EditRole) diff --git a/activity_browser/ui/widgets/central.py b/activity_browser/ui/widgets/central.py index 4215751d7..0e5c789e2 100644 --- a/activity_browser/ui/widgets/central.py +++ b/activity_browser/ui/widgets/central.py @@ -5,9 +5,6 @@ from activity_browser.app import signals - - - class CentralTabWidget(QtWidgets.QTabWidget): """ A custom QTabWidget that manages groups of tabs and their associated pages. @@ -59,6 +56,7 @@ def addToGroup(self, group: str, page: QtWidgets.QWidget): self.addTab(GroupTabWidget(group, self), group) group = self.groups[group] + self.setCurrentWidget(group) # Check if the page already exists in the group page_names = [group.widget(i).objectName() for i in range(group.count())] @@ -68,16 +66,14 @@ def addToGroup(self, group: str, page: QtWidgets.QWidget): page.setWindowTitle(name) # make sure the page has a title page.setParent(group) group.addTab(page, name) + group.setCurrentWidget(page) page.windowTitleChanged.connect(lambda title: group.setTabText(group.indexOf(page), title)) else: # Set the existing page as the current tab index = page_names.index(page.objectName()) group.setCurrentIndex(index) - - # Set the group and page as the current widgets - self.setCurrentWidget(group) - group.setCurrentWidget(page) + page.deleteLater() # Clean up the newly created page since it already exists def reset(self): self.setCurrentIndex(0) diff --git a/activity_browser/ui/widgets/menu.py b/activity_browser/ui/widgets/menu.py index 52f332749..6d7e667fd 100644 --- a/activity_browser/ui/widgets/menu.py +++ b/activity_browser/ui/widgets/menu.py @@ -4,7 +4,7 @@ class ABMenu(QtWidgets.QMenu): - menuSetup: list[Callable[["ABMenu", Optional[QtWidgets.QWidget]], None]] + menuSetup: list[Callable[["ABMenu", QtWidgets.QWidget], None]] title: str = None def __init__(self, pos=None, parent=None, title: str = None): From 0347db95d05d6e17917059710d4a1721b77434c8 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sun, 9 Nov 2025 16:34:42 +0100 Subject: [PATCH 099/267] Add GraphBackend class for improved communication between Python and JavaScript --- .../app/pages/activity_details/graph_tab.py | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/activity_browser/app/pages/activity_details/graph_tab.py b/activity_browser/app/pages/activity_details/graph_tab.py index 5ebd35ce1..ebb28d61c 100644 --- a/activity_browser/app/pages/activity_details/graph_tab.py +++ b/activity_browser/app/pages/activity_details/graph_tab.py @@ -25,6 +25,7 @@ class GraphTab(QtWidgets.QWidget): expanded_nodes (set): A set of node IDs that are expanded in the graph. button (QtWidgets.QPushButton): A button to trigger synchronization. bridge (Bridge): A bridge object for communication between Python and JavaScript. + backend (GraphBackend): A backend object for communication between Python and JavaScript. url (QUrl): The URL of the HTML file to display. channel (QtWebChannel.QWebChannel): A web channel for communication between Python and JavaScript. page (Page): A web engine page to display the HTML content. @@ -48,11 +49,12 @@ def __init__(self, activity, parent=None): self.button.clicked.connect(self.sync) self.bridge = Bridge(self) + self.backend = GraphBackend(self) self.url = QUrl.fromLocalFile(os.path.join(static.__path__[0], "activity_graph.html")) self.channel = QtWebChannel.QWebChannel(self) self.channel.registerObject("bridge", self.bridge) - self.channel.registerObject("backend", self) + self.channel.registerObject("backend", self.backend) self.page = Page() self.page.setWebChannel(self.channel) @@ -143,7 +145,6 @@ def build_json(self): return json.dumps(full) - @Slot(str) def expand_node(self, node_id: str): """ Expands a node in the graph. @@ -158,7 +159,6 @@ def expand_node(self, node_id: str): self.expanded_nodes.add(node.id) self.sync() - @Slot(str) def collapse_node(self, node_id: str): """ Collapses a node in the graph. @@ -247,6 +247,44 @@ def dropEvent(self, event): app.actions.ExchangeNew.run(keys, self.parent().activity.key, exc_type) +class GraphBackend(QObject): + """ + A backend object for communication between Python and JavaScript. + This object is exposed to the JavaScript side and provides methods + that can be called from JavaScript to control the graph. + """ + def __init__(self, graph_tab: GraphTab, parent=None): + """ + Initializes the GraphBackend object. + + Args: + graph_tab (GraphTab): The GraphTab widget this backend is associated with. + parent (QObject, optional): The parent object. Defaults to None. + """ + super().__init__(parent) + self.graph_tab = graph_tab + + @Slot(str) + def expand_node(self, node_id: str): + """ + Expands a node in the graph. + + Args: + node_id (str): The ID of the node to expand. + """ + self.graph_tab.expand_node(node_id) + + @Slot(str) + def collapse_node(self, node_id: str): + """ + Collapses a node in the graph. + + Args: + node_id (str): The ID of the node to collapse. + """ + self.graph_tab.collapse_node(node_id) + + class Bridge(QObject): """ A bridge for communication between Python and JavaScript. From 911ff6208340f16063d7ccfe3f6e6068a3d732e7 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sun, 9 Nov 2025 17:02:59 +0100 Subject: [PATCH 100/267] Fixed the data tab to the new table --- .../app/pages/activity_details/data_tab.py | 14 ++++++-------- activity_browser/bwutils/commontasks.py | 6 +++--- activity_browser/ui/delegates/new_formula.py | 2 -- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/activity_browser/app/pages/activity_details/data_tab.py b/activity_browser/app/pages/activity_details/data_tab.py index b7d94464f..05253695b 100644 --- a/activity_browser/app/pages/activity_details/data_tab.py +++ b/activity_browser/app/pages/activity_details/data_tab.py @@ -38,8 +38,7 @@ def __init__(self, activity: tuple | int | bd.Node, parent=None): df = self.build_df() df.reset_index(drop=True, inplace=True) self.data_model.set_dataframe(df) - self.data_model.group(["name"]) - self.data_view.setColumnHidden(1, True) + self.data_model.group(["_name"]) self.data_view.expandAll() self.build_layout() @@ -60,8 +59,7 @@ def sync(self) -> None: df = self.build_df() df.reset_index(drop=True, inplace=True) self.data_model.set_dataframe(df) - self.data_model.group(["name"]) - self.data_view.setColumnHidden(1, True) + self.data_model.group(["_name"]) self.data_view.expandAll() def build_df(self) -> pd.DataFrame: @@ -72,23 +70,23 @@ def build_df(self) -> pd.DataFrame: pd.DataFrame: The DataFrame containing the activity data. """ df = pd.Series(self.activity.as_dict()).to_frame() - df["name"] = f"{self.activity['name']} {df.get('product', '')} ({self.activity['id']})" + df["_name"] = f"{self.activity['name']} {df.get('product', '')} ({self.activity['id']})" df["_activity_id"] = self.activity.id df["_activity_db"] = self.activity["database"] if isinstance(self.activity, bf.Process): for product in self.activity.products(): fn_df = pd.DataFrame.from_dict(product.as_dict(), orient="index") - fn_df["name"] = f"{product['name']}: {product.get('product', '')} ({product['id']})" + fn_df["_name"] = f"{product['name']}: {product.get('product', '')} ({product['id']})" fn_df["_activity_id"] = product.id fn_df["_activity_db"] = product["database"] df = pd.concat([df, fn_df]) df = df.reset_index() df = df.rename({"index": "field", 0: "value"}, axis=1) - df = df.sort_values(["name", "field"], ignore_index=True) + df = df.sort_values(["_name", "field"], ignore_index=True) - cols = ["field", "value", "name", "_activity_id", "_activity_db"] + cols = ["field", "value", "_name", "_activity_id", "_activity_db"] return df[cols] diff --git a/activity_browser/bwutils/commontasks.py b/activity_browser/bwutils/commontasks.py index c24bd0004..23c034b66 100644 --- a/activity_browser/bwutils/commontasks.py +++ b/activity_browser/bwutils/commontasks.py @@ -6,7 +6,7 @@ import arrow import pandas as pd -import peewee as pw +import numpy as np import bw2data as bd from bw2data.parameters import ParameterBase, ProjectParameter, DatabaseParameter, ActivityParameter, Group @@ -225,12 +225,12 @@ def is_node_process(node: tuple | int | bd.Node) -> bool: return False -def refresh_node(node: tuple | int | bd.Node) -> bd.Node: +def refresh_node(node: tuple | int | np.int64 | bd.Node) -> bd.Node: if isinstance(node, bd.Node): node = bd.get_node(id=node.id) elif isinstance(node, tuple): node = bd.get_node(key=node) - elif isinstance(node, int): + elif isinstance(node, (int, np.int64)): node = bd.get_node(id=node) else: raise ValueError("Activity must be either a tuple, int or Node instance") diff --git a/activity_browser/ui/delegates/new_formula.py b/activity_browser/ui/delegates/new_formula.py index aaa753ce0..6f43e3755 100644 --- a/activity_browser/ui/delegates/new_formula.py +++ b/activity_browser/ui/delegates/new_formula.py @@ -34,7 +34,6 @@ def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): elif hasattr(index.model(), 'scoped_parameters'): scope = index.model().scoped_parameters(index) else: - logger.warning("No scope found for formula editor. Define `scoped_parameters` attribute in index model.") scope = {} from activity_browser.ui.widgets import ABFormulaEdit @@ -56,7 +55,6 @@ def createEditor(self, parent, option, index): elif hasattr(index.model(), 'scoped_parameters'): scope = index.model().scoped_parameters(index) else: - logger.warning("No scope found for formula editor. Define `scoped_parameters` attribute in index model.") scope = {} editor = ABFormulaEdit(parent, scope) return editor From 882e75a4fed29bfaa5623174b8b7854eede5ed86 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sun, 9 Nov 2025 17:18:38 +0100 Subject: [PATCH 101/267] Move activity details parameters tab --- .../pages/activity_details/parameters_tab.py | 310 +++++++----------- 1 file changed, 111 insertions(+), 199 deletions(-) diff --git a/activity_browser/app/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py index e76e245f6..2dc2cc2b1 100644 --- a/activity_browser/app/pages/activity_details/parameters_tab.py +++ b/activity_browser/app/pages/activity_details/parameters_tab.py @@ -1,10 +1,11 @@ from qtpy import QtWidgets, QtCore +from qtpy.QtCore import Qt import pandas as pd import bw2data as bd -from activity_browser import app, app -from activity_browser.ui import widgets, icons, delegates +from activity_browser import app +from activity_browser.ui import widgets, icons, delegates, core from activity_browser.bwutils.commontasks import refresh_node, refresh_parameter, parameters_in_scope, database_is_locked, node_group from activity_browser.bwutils.utils import Parameter @@ -29,13 +30,9 @@ def __init__(self, activity, parent=None): super().__init__(parent) self.activity = refresh_node(activity) - self.model = ParametersModel(self.build_df(), self.activity, self) - self.view = ParametersView() + self.view = ParametersView(self) + self.model = ParametersModel(tab=self) self.view.setModel(self.model) - self.view.expandAll() - - self.view.resizeColumnToContents(0) - self.view.resizeColumnToContents(2) self.build_layout() self.connect_signals() @@ -45,6 +42,7 @@ def build_layout(self): Builds the layout of the widget. """ layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 10, 0, 1) layout.addWidget(self.view) self.setLayout(layout) @@ -62,7 +60,11 @@ def sync(self): Synchronizes the widget with the current state of the activity. """ self.activity = refresh_node(self.activity) - self.model.setDataFrame(self.build_df()) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) + self.model.group(["_scope"]) + self.view.expandAll() def build_df(self) -> pd.DataFrame: """ @@ -84,9 +86,9 @@ def build_df(self) -> pd.DataFrame: row["_activity"] = self.activity if param.param_type == "project": - row["_scope"] = f"Current project" + row["_scope"] = "Current project" elif param.param_type == "database": - row["_scope"] = f"This database" + row["_scope"] = "This database" elif param.group == node_group(self.activity): row["_scope"] = "This activity" else: @@ -98,7 +100,7 @@ def build_df(self) -> pd.DataFrame: return pd.DataFrame(translated, columns=columns) -class ParametersView(widgets.ABTreeView): +class ParametersView(widgets.ABNewTreeView): """ A view that displays the parameters in a tree structure. @@ -113,233 +115,143 @@ class ParametersView(widgets.ABTreeView): "uncertainty": delegates.UncertaintyDelegate, } - class ContextMenu(QtWidgets.QMenu): - """ - A context menu for the ParametersView. - - Attributes: - del_param_action (QAction): The action to delete a parameter. - """ - def __init__(self, pos, view: "ParametersView"): - """ - Initializes the ContextMenu. - - Args: - pos: The position of the context menu. - view (ParametersView): The view displaying the parameters. - """ - super().__init__(view) - - index = view.indexAt(pos) - if index.isValid() and isinstance(index.internalPointer(), ParametersItem): - item = index.internalPointer() - param = item.parameter.to_peewee_model() - self.del_param_action = app.actions.ParameterDelete().get_QAction(param) - if not param.is_deletable() or param.name == "dummy_parameter": - self.del_param_action.setEnabled(False) - self.addAction(self.del_param_action) - - -class ParametersItem(widgets.ABDataItem): - """ - An item representing a parameter in the tree view. - """ - - @property - def scoped_parameters(self) -> dict[str, Parameter]: - """ - Returns the parameters in scope of this item's parameter. + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m: m.add(app.actions.ParameterDelete, m.parameters, enable=bool(m.parameters) and not m.locked), + ] + + @property + def locked(self): + table_view: ParametersView = self.parent() + return database_is_locked(table_view.activity["database"]) + + @property + def activity(self): + table_view: ParametersView = self.parent() + return table_view.activity + + @property + def parameters(self): + table_view: ParametersView = self.parent() + table_model: ParametersModel = table_view.model() + + selected_indices = table_view.selectedIndexes() + params = table_model.values_from_indices("_parameter", selected_indices) + # Convert to peewee models + return [p.to_peewee_model() for p in params if p is not None] + + def __init__(self, parent): + """ + Initializes the ParametersView. - Returns: - dict: The parameters in scope. + Args: + parent (QtWidgets.QWidget): The parent widget. """ - return parameters_in_scope(parameter=self["_parameter"]) + super().__init__(parent) + self.setSortingEnabled(True) @property - def parameter(self) -> Parameter: - """ - Returns the parameter associated with this item. - - Returns: - Parameter: The current parameter. - """ - return refresh_parameter(self["_parameter"]) - - def flags(self, col: int, key: str): + def activity(self): """ - Returns the item flags for the given column and key. - - Args: - col (int): The column index. - key (str): The key for which to return the flags. + Returns the activity associated with the view. Returns: - QtCore.Qt.ItemFlags: The item flags. + The activity associated with the view. """ - flags = super().flags(col, key) + return self.parent().activity - if key in ["amount", "formula", "uncertainty", "name", "comment"] and not database_is_locked(self["_activity"]["database"]): - return flags | QtCore.Qt.ItemFlag.ItemIsEditable - return flags - def setData(self, col: int, key: str, value) -> bool: +class ParametersModel(core.ABTreeModel): + """ + A model representing the data for the parameters. + """ + def __init__(self, tab: ParametersTab): + super().__init__(parent=tab) + self.tab = tab + + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: """ - Sets the data for the given column and key. + Sets the data for the given index. Args: - col (int): The column index. - key (str): The key for which to set the data. + index (QtCore.QModelIndex): The index to set data for. value: The value to set. + role (int): The role for which to set the data. Returns: bool: True if the data was set successfully, False otherwise. """ - if key in ["amount", "formula", "name", "comment"]: - app.actions.ParameterModify.run(self.parameter, key, value) - - return False - - def decorationData(self, col, key): - """ - Provides decoration data for the item. + if role != Qt.ItemDataRole.EditRole: + return False - Args: - col: The column index. - key: The key for which to provide decoration data. + column_name = self.column_name(index) + row = self.row(index) - Returns: - The decoration data for the item. - """ - if key not in ["amount"]: - return + if row is None: + return False - if key == "amount": - if pd.isna(self["formula"]) or self["formula"] is None or self["formula"] == "": - return icons.qicons.empty # empty icon to align the values - return icons.qicons.parameterized + parameter = row.get("_parameter") + if parameter is None: + return False + if column_name in ["amount", "formula", "name", "comment"]: + parameter = refresh_parameter(parameter) + app.actions.ParameterModify.run(parameter, column_name, value) + return True -class NewParametersItem(widgets.ABDataItem): - """ - An item representing a new parameter in the tree view. - """ - def flags(self, col: int, key: str): + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: """ - Returns the item flags for the given column and key. + Provides decoration data for the model. Args: - col (int): The column index. - key (str): The key for which to return the flags. + index (QtCore.QModelIndex): The index for which to provide decoration data. Returns: - QtCore.Qt.ItemFlags: The item flags. + The decoration data for the index. """ - flags = super().flags(col, key) - if key == "name": - return flags | QtCore.Qt.ItemFlag.ItemIsEditable - return flags + column_name = self.column_name(index) + row = self.row(index) - def fontData(self, col: int, key: str): - """ - Returns the font data for the given column and key. + if row is None: + return None + + if not isinstance(row, pd.Series): + return None - Args: - col (int): The column index. - key (str): The key for which to return the font data. - - Returns: - QtGui.QFont: The font data. - """ - font = super().fontData(col, key) - font.setWeight(font.Weight.ExtraLight) - return font + if column_name == "amount": + if pd.isna(row.get("formula")) or row.get("formula") is None or row.get("formula") == "": + return icons.qicons.empty # empty icon to align the values + return icons.qicons.parameterized - def setData(self, col: int, key: str, value) -> bool: - """ - Sets the data for the given column and key. + return None - Args: - col (int): The column index. - key (str): The key for which to set the data. - value: The value to set. + def indexEditable(self, index): + column_name = self.column_name(index) + row = self.row(index) - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if key != "name" or value == "": + # Prevent editing if the database is locked + if row is None or database_is_locked(row.get("_activity", {}).get("database")): return False - parameter = Parameter( - name=value, - group=self["_parameter"]["group"], - param_type=self["_parameter"]["param_type"] - ) - - app.actions.ParameterNewFromParameter.run(parameter) - return True - + # Allow editing for specific columns + if column_name in ["amount", "formula", "uncertainty", "name", "comment"]: + return True -class ParametersModel(widgets.ABItemModel): - """ - A model representing the data for the parameters. - - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = ParametersItem - - def __init__(self, dataframe, activity, parent=None): - """ - Initializes the ParametersModel. - - Args: - dataframe (pd.DataFrame): The DataFrame containing the parameters data. - activity (tuple | int | bd.Node): The activity to display parameters for. - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - self.activity = activity - super().__init__(parent, dataframe) - - def createItems(self, dataframe=None) -> list[widgets.ABAbstractItem]: + return False + + def scoped_parameters(self, index): """ - Creates items from the given DataFrame. + Returns the scoped parameters for the index. Args: - dataframe (pd.DataFrame, optional): The DataFrame containing the parameters data. Defaults to None. + index (QtCore.QModelIndex): The index to get scoped parameters for. Returns: - list[widgets.ABAbstractItem]: The list of created items. + dict: A dictionary of scoped parameters for the index. """ - if dataframe is None: - # If no DataFrame is provided, use the model's default DataFrame. - dataframe = self.dataframe - - items = [] - for scope in ["Current project", "This database", "This activity"]: - # Create a branch item for the current scope. - branch = self.branchItemClass(scope) - - # Iterate over the rows in the DataFrame that match the current scope. - for index, data in dataframe.loc[dataframe._scope == scope].to_dict(orient="index").items(): - # Create a data item for each row and add it to the branch. - self.dataItemClass(index, data, branch) - - # Determine the group and parameter type based on the current scope. - if scope == "Current project": - group, param_type = "project", "project" - elif scope == "This database": - group, param_type = self.activity["database"], "database" - else: - group, param_type = self.activity.id, "activity" - - # If the database is not read-only, add a placeholder for creating a new parameter. - if not bd.databases[self.activity["database"]].get("read_only", True): - NewParametersItem(None, {"name": "New parameter...", "_parameter": { - "group": group, "param_type": param_type - }}, branch) - - # Add the branch to the list of items. - items.append(branch) - - # Return the list of created items. - return items + row = self.row(index) + if row is None: + return {} + return parameters_in_scope(parameter=row.get("_parameter")) From 7bbf0bca969afee72084475a4e7a2acac14f42e9 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 10 Nov 2025 10:13:34 +0100 Subject: [PATCH 102/267] Optimizations --- .../app/pages/activity_details/data_tab.py | 6 +- .../pages/activity_details/exchanges_tab.py | 24 +- .../impact_category_details.py | 132 +++++--- .../app/panes/database_products.py | 7 +- .../app/panes/impact_categories.py | 20 +- activity_browser/ui/core/__init__.py | 2 +- activity_browser/ui/core/tree_model.py | 313 +++++++++++------- 7 files changed, 306 insertions(+), 198 deletions(-) diff --git a/activity_browser/app/pages/activity_details/data_tab.py b/activity_browser/app/pages/activity_details/data_tab.py index 05253695b..0920025a4 100644 --- a/activity_browser/app/pages/activity_details/data_tab.py +++ b/activity_browser/app/pages/activity_details/data_tab.py @@ -172,8 +172,10 @@ def displayData(self, index: QtCore.QModelIndex) -> any: if row is None: # Branch node - path = index.internalPointer() - return path[-1] if index.column() == 0 else None + node = index.internalPointer() + if isinstance(node, core.TreeNode): + return node.path[-1] if index.column() == 0 else None + return None if column_name == "value": data = row.get(column_name) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index 7cb9d103d..b7a788f64 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -560,16 +560,9 @@ def decorationData(self, index: QtCore.QModelIndex) -> any: The decoration data for the index. """ column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return None - - if not isinstance(row, pd.Series): - pass if column_name in ["product", "producer"]: - activity_type = row.get("_producer_type") + activity_type = self.get(index, "_producer_type") if activity_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: return icons.qicons.biosphere if column_name == "producer" else None if activity_type == "processwithreferenceproduct": @@ -580,7 +573,7 @@ def decorationData(self, index: QtCore.QModelIndex) -> any: return icons.qicons.process if column_name == "producer" else icons.qicons.waste if column_name == "amount": - formula = row.get("formula") + formula = self.get(index, "formula") if pd.isna(formula) or formula is None or formula == "": return None return icons.qicons.parameterized @@ -606,12 +599,13 @@ def fontData(self, index: QtCore.QModelIndex) -> any: def indexEditable(self, index): column_name = self.column_name(index) - row = self.row(index) - functional = self.functional(index) + database = self.get(index, "_exchange")["output"][0] # Prevent editing if the database is locked - if database_is_locked(row["_exchange"]["output"][0]): + if database_is_locked(database): return False + + functional = self.functional(index) # Allow editing for specific keys: "amount", "formula", and "uncertainty". if column_name in ["amount", "formula", "uncertainty", "comment"]: @@ -645,7 +639,7 @@ def functional(self, index): Returns: bool: True if the index is functional, False otherwise. """ - return self.row(index).get("_exchange_type") == "production" + return self.get(index, "_exchange_type") == "production" def scoped_parameters(self, index): """ @@ -657,6 +651,6 @@ def scoped_parameters(self, index): Returns: list: A list of scoped parameters for the index. """ - row = self.row(index) - return parameters_in_scope(row["_exchange"].output) + exchange = self.get(index, "_exchange") + return parameters_in_scope(exchange.output) \ No newline at end of file diff --git a/activity_browser/app/pages/impact_category_details/impact_category_details.py b/activity_browser/app/pages/impact_category_details/impact_category_details.py index 12fd61883..4569c9aa5 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_details.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_details.py @@ -1,11 +1,11 @@ -from qtpy import QtWidgets, QtGui +from qtpy import QtWidgets, QtGui, QtCore from qtpy.QtCore import Qt import bw2data as bd import pandas as pd -from activity_browser import app, app -from activity_browser.ui import widgets, icons, delegates +from activity_browser import app +from activity_browser.ui import widgets, icons, delegates, core from activity_browser.bwutils.commontasks import is_node_biosphere from .impact_category_header import ImpactCategoryHeader @@ -22,19 +22,14 @@ def __init__(self, name: tuple, parent=None): self.header = ImpactCategoryHeader(self) - self.model = CharacterizationFactorsModel(self) self.view = CharacterizationFactorsView(self) + self.model = CharacterizationFactorsModel(page=self) self.view.setModel(self.model) - self.view.setSortingEnabled(True) self.build_layout() self.connect_signals() self.sync() - # resizing name and categories columns - self.view.resizeColumnToContents(0) - self.view.resizeColumnToContents(1) - def connect_signals(self): app.signals.method.renamed.connect(self.on_method_renamed) app.signals.method.deleted.connect(self.on_method_deleted) @@ -56,7 +51,9 @@ def sync(self): return self.impact_category = bd.Method(self.name) - self.model.setDataFrame(self.build_df()) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) self.header.sync() def build_layout(self): @@ -81,7 +78,7 @@ def build_df(self): return df[cols] -class CharacterizationFactorsView(widgets.ABTreeView): +class CharacterizationFactorsView(widgets.ABNewTreeView): defaultColumnDelegates = { "amount": delegates.FloatDelegate, "categories": delegates.ListDelegate, @@ -97,17 +94,23 @@ class ContextMenu(widgets.ABMenu): @property def is_editable(self): - return self.parent().parent().is_editable + table_view: CharacterizationFactorsView = self.parent() + return table_view.page.is_editable @property def impact_category_name(self): - return self.parent().parent().name + table_view: CharacterizationFactorsView = self.parent() + return table_view.page.name @property def char_factors(self): - indexes = self.parent().selectedIndexes() - return [(idx.internalPointer()["_id"], idx.internalPointer()["_cf"]) - for idx in indexes if idx.isValid() and idx.column() == 0] + table_view: CharacterizationFactorsView = self.parent() + table_model: CharacterizationFactorsModel = table_view.model() + + selected_indices = table_view.selectedIndexes() + ids = table_model.values_from_indices("_id", selected_indices) + cfs = table_model.values_from_indices("_cf", selected_indices) + return list(zip(ids, cfs)) def __init__(self, parent): super().__init__(parent) @@ -115,6 +118,11 @@ def __init__(self, parent): self.setSortingEnabled(True) self.overlay = None + @property + def page(self): + """Returns the ImpactCategoryDetailsPage associated with the view.""" + return self.parent() + def dragEnterEvent(self, event): """ Handles the drag enter event. @@ -175,73 +183,89 @@ def dropEvent(self, event): app.actions.CFNew.run(self.parent().name, biosphere_keys) -class CharacterizationFactorsItem(widgets.ABDataItem): - def flags(self, col: int, key: str): +class CharacterizationFactorsModel(core.ABTreeModel): + """ + A model representing the characterization factors data. + """ + def __init__(self, page: ImpactCategoryDetailsPage): + super().__init__(parent=page) + self.page = page + + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: """ - Returns the item flags for the given column and key. + Sets the data for the given index. Args: - col (int): The column index. - key (str): The key for which to return the flags. + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. Returns: - QtCore.Qt.ItemFlags: The item flags. + bool: True if the data was set successfully, False otherwise. """ - flags = super().flags(col, key) + if role != Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False - if key in ["amount", "uncertainty"] and self["_editable"]: - return flags | Qt.ItemFlag.ItemIsEditable - return flags + if column_name == "amount": + app.actions.CFAmountModify.run(row["_impact_category_name"], row["_id"], value) + return True - def decorationData(self, col, key): + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: """ - Provides decoration data for the item. + Provides decoration data for the model. Args: - col: The column index. - key: The key for which to provide decoration data. + index (QtCore.QModelIndex): The index for which to provide decoration data. Returns: - The decoration data for the item. + The decoration data for the index. """ - if key == "name": + column_name = self.column_name(index) + if column_name == "name": return icons.qicons.biosphere - def fontData(self, col: int, key: str): + return None + + def fontData(self, index: QtCore.QModelIndex) -> any: """ - Returns the font data for the given column and key. + Provides font data for the model. Args: - col (int): The column index. - key (str): The key for which to return the font data. + index (QtCore.QModelIndex): The index for which to provide font data. Returns: - QtGui.QFont: The font data. + QtGui.QFont: The font data for the index. """ - font = super().fontData(col, key) - - # set the font to bold if it's a production/functional exchange - if key == "name": + column_name = self.column_name(index) + if column_name == "name": + font = QtGui.QFont() font.setWeight(QtGui.QFont.Weight.DemiBold) - return font + return font + + return None - def setData(self, col: int, key: str, value) -> bool: + def indexEditable(self, index): """ - Sets the data for the given column and key. + Returns whether the index is editable. Args: - col (int): The column index. - key (str): The key for which to set the data. - value: The value to set. + index (QtCore.QModelIndex): The index to check. Returns: - bool: True if the data was set successfully, False otherwise. + bool: True if the index is editable, False otherwise. """ - if key not in ["amount"]: - return False - - app.actions.CFAmountModify.run(self["_impact_category_name"], self["_id"], value) + column_name = self.column_name(index) + # Allow editing for amount and uncertainty if editable + if column_name in ["amount", "uncertainty"] and self.get(index, "_editable"): + return True + return False -class CharacterizationFactorsModel(widgets.ABItemModel): - dataItemClass = CharacterizationFactorsItem diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index cefb588e0..f777f206a 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -340,12 +340,7 @@ def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: #-- data overrides --- def decorationData(self, index: QtCore.QModelIndex) -> any: column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return None - - node_type = row.get("type", "").lower() + node_type = self.get(index, "type") if column_name not in ["name", "product"]: return None diff --git a/activity_browser/app/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py index 0af672c63..5089f0721 100644 --- a/activity_browser/app/panes/impact_categories.py +++ b/activity_browser/app/panes/impact_categories.py @@ -164,22 +164,24 @@ def get_impact_categories(self, index: QtCore.QModelIndex): if not index.isValid(): return [] - path = index.internalPointer() + node = index.internalPointer() - # If this is a leaf node (full depth), return its method name - if len(path) == self.df.index.nlevels: + if not isinstance(node, core.TreeNode): + return [] + + # If this is a leaf node, return its method name + if node.is_leaf: row = self.row(index) if row is not None: return [row["_method_name"]] return [] - # If this is a branch node, collect all child method names + # If this is a branch node, collect all child method names recursively ics = [] - children = self.children_map.get(path, []) - for child_path in children: - # Create an index for the child and recursively get its impact categories - row_idx = self.row_indices[path][child_path] - child_index = self.createIndex(row_idx, 0, child_path) + for i, child_node in enumerate(node.children): + if i >= node.loaded_count: + break # Only process loaded children + child_index = self.createIndex(i, 0, child_node) ics += self.get_impact_categories(child_index) return ics diff --git a/activity_browser/ui/core/__init__.py b/activity_browser/ui/core/__init__.py index 28570446f..ab50b2c48 100644 --- a/activity_browser/ui/core/__init__.py +++ b/activity_browser/ui/core/__init__.py @@ -1,2 +1,2 @@ from .mimedata import ABMimeData -from .tree_model import ABTreeModel +from .tree_model import ABTreeModel, TreeNode diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 2285727b6..5eb2f7b1c 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -8,30 +8,61 @@ from PySide6.QtCore import QAbstractItemModel +class TreeNode: + """ + Optimized node object that combines children_map, row_indices, loaded_counts, + and DataFrame position for O(1) lookups. + """ + __slots__ = ('path', 'children', 'row_in_parent', 'loaded_count', 'df_position', 'is_leaf', '_child_lookup') + + def __init__(self, path: tuple, df_position: int = -1): + self.path: tuple = path # Full path tuple for this node + self.children: list['TreeNode'] = [] # List of child nodes + self.row_in_parent: int = -1 # Row index within parent's children list + self.loaded_count: int = 0 # Number of children currently loaded (for lazy loading) + self.df_position: int = df_position # Integer position in DataFrame (-1 for branch nodes) + self.is_leaf: bool = (df_position >= 0) # True if this is a leaf node + self._child_lookup: dict[tuple, TreeNode] = {} # Fast child lookup by path + + def add_child(self, child: 'TreeNode') -> None: + """Add a child node and update its row_in_parent.""" + child.row_in_parent = len(self.children) + self.children.append(child) + self._child_lookup[child.path] = child + + def get_child(self, path: tuple) -> Optional['TreeNode']: + """Get a child by its path (O(1) lookup).""" + return self._child_lookup.get(path) + + def get_child_at(self, row: int) -> Optional['TreeNode']: + """Get a child by its row index (O(1) lookup).""" + if 0 <= row < len(self.children): + return self.children[row] + return None + + def total_children(self) -> int: + """Total number of children (for lazy loading comparison).""" + return len(self.children) + + def can_fetch_more(self) -> bool: + """Check if more children can be loaded.""" + return self.loaded_count < len(self.children) + + class ABTreeModel(QAbstractItemModel): def __init__(self, df: pd.DataFrame = None, parent: Optional[QWidget] = None, chunk_size: int = -1) -> None: super().__init__(parent) self.df = df if df is not None else pd.DataFrame() self.df_query: dict[str, str] = {"model": "index == index"} # dictionary where queries can be registered - self.children_map = self.build_hierarchy_from_index(self.df.index) self.lazy = chunk_size > 0 self.chunk_size = chunk_size - # Pre-compute row indices for O(1) lookups instead of O(n) list.index() - self.row_indices: dict[tuple, dict[tuple, int]] = {} - self.build_row_indices() + # Single unified node map: path -> TreeNode + self.node_map: dict[tuple, TreeNode] = {} + self.root: TreeNode = TreeNode(tuple()) # Root node with empty path - # Track how many children are currently loaded for each parent - self.loaded_counts: dict[tuple, int] = {} - if self.lazy: - # Initially load first chunk for each parent - for parent_path in self.children_map: - total = len(self.children_map[parent_path]) - self.loaded_counts[parent_path] = min(chunk_size, total) - else: - # All rows are loaded - for parent_path, children in self.children_map.items(): - self.loaded_counts[parent_path] = len(children) + # Build the node hierarchy + self.build_node_hierarchy(self.df.index) def columns(self) -> list[str]: """Return the list of column names, including the tree column.""" @@ -42,56 +73,71 @@ def column_name(self, index: QModelIndex) -> str: return self.columns()[index.column()] def row(self, index: QModelIndex) -> pd.Series | None: - """Return the DataFrame row corresponding to the given index, or None for non-leaf nodes.""" + """ + Return the DataFrame row corresponding to the given index, or None for non-leaf nodes. + + Warning: This is a slow operation and should be avoided in methods called frequently like data(), *Data(), flags(), or index*(). + """ if not index.isValid(): return None - path = index.internalPointer() + node = index.internalPointer() - # Only return data for leaf nodes (full depth paths) - if len(path) < self.df.index.nlevels: + if not isinstance(node, TreeNode) or not node.is_leaf: return None - row = self.df.loc[path] - - if not isinstance(row, pd.Series): - pass + # Use the pre-computed df_position for fast access + return self.df.iloc[node.df_position] + + def get(self, index: QModelIndex, column: str | int) -> any: + """ + Get the data for the given QModelIndex and column name or index. + """ + if not index.isValid(): + return None - return row + node = index.internalPointer() + + if not isinstance(node, TreeNode) or not node.is_leaf: + return None + + column_i = column if isinstance(column, int) else self.df.columns.get_loc(column) + + return self.df.iat[node.df_position, column_i] + # --- required model overrides --- def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex: - parent_path = parent.internalPointer() or tuple() - all_children = self.children_map.get(parent_path, []) - - if not 0 <= row < len(all_children): - return QModelIndex() + parent_node = parent.internalPointer() if parent.isValid() else self.root - if parent_path != tuple(): - pass - - # children_map now stores full child paths; use directly - child_path = all_children[row] - - return self.createIndex(row, column, child_path) + if not isinstance(parent_node, TreeNode): + parent_node = self.root + + child_node = parent_node.get_child_at(row) + + if child_node is None: + return QModelIndex() + return self.createIndex(row, column, child_node) def parent(self, index: QModelIndex) -> QModelIndex: if not index.isValid(): return QModelIndex() - # Full path for the current index - path = index.internalPointer() - parent_path = self.parent_path(path) + node = index.internalPointer() + if not isinstance(node, TreeNode): + return QModelIndex() + + parent_path = self.parent_path(node.path) if len(parent_path) == 0: return QModelIndex() - grandparent_path = self.parent_path(parent_path) - # Use pre-computed row index for O(1) lookup instead of O(n) list.index() - row = self.row_indices[grandparent_path][parent_path] + parent_node = self.node_map.get(parent_path) + if parent_node is None: + return QModelIndex() - return self.createIndex(row, 0, self.children_map[grandparent_path][row]) + return self.createIndex(parent_node.row_in_parent, 0, parent_node) def parent_path(self, path: tuple) -> tuple: path = tuple(val for val in path if not pd.isna(val)) @@ -102,10 +148,13 @@ def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: if parent.isValid() and parent.column() != 0: return 0 - parent_path = parent.internalPointer() or tuple() + parent_node = parent.internalPointer() if parent.isValid() else self.root + + if not isinstance(parent_node, TreeNode): + parent_node = self.root # Return the number of currently loaded children - return self.loaded_counts.get(parent_path, 0) + return parent_node.loaded_count def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802 (Qt signature) # Always return the full column count for consistent tree structure @@ -130,18 +179,25 @@ def data(self, index: QModelIndex, role: int = Qt.DisplayRole): return None def displayData(self, index: QModelIndex) -> any: - path = index.internalPointer() + node = index.internalPointer() - if len(path) < self.df.index.nlevels: # branch node + if not isinstance(node, TreeNode): + return None + + if not node.is_leaf: # branch node # For branch nodes, show the name in the first column only # (spanning will be handled by the view) - return path[-1] if index.column() == 0 else None + return node.path[-1] if index.column() == 0 else None if index.column() == 0: return None # leaf node tree column is empty - col_name = self.headerData(index.column()) - val = self.df.at[path, col_name] + # Use the pre-computed df_position for O(1) iloc access + col_idx = index.column() - 1 # Adjust for tree column + if col_idx < 0 or col_idx >= len(self.df.columns): + return None + + val = self.df.iat[node.df_position, col_idx] if not hasattr(val, "__iter__") and pd.isna(val): return None @@ -199,8 +255,10 @@ def isBranchNode(self, index: QModelIndex) -> bool: """Check if the given index represents a branch node (non-leaf).""" if not index.isValid(): return False - path = index.internalPointer() - return len(path) < self.df.index.nlevels + node = index.internalPointer() + if not isinstance(node, TreeNode): + return False + return not node.is_leaf def headerData(self, section: int, orientation: Qt.Orientation = Qt.Horizontal, role: int = Qt.DisplayRole): if orientation == Qt.Vertical or not role == Qt.DisplayRole: @@ -216,23 +274,25 @@ def canFetchMore(self, parent: QModelIndex) -> bool: if not self.lazy: return False - parent_path = parent.internalPointer() or tuple() + parent_node = parent.internalPointer() if parent.isValid() else self.root - # Can fetch more if we have more children than currently loaded - total_children = len(self.children_map.get(parent_path, [])) - loaded = self.loaded_counts.get(parent_path, 0) + if not isinstance(parent_node, TreeNode): + parent_node = self.root - return loaded < total_children + return parent_node.can_fetch_more() def fetchMore(self, parent: QModelIndex) -> None: """Load the next chunk of children when user scrolls.""" if not self.lazy: return - parent_path = parent.internalPointer() or tuple() + parent_node = parent.internalPointer() if parent.isValid() else self.root + + if not isinstance(parent_node, TreeNode): + parent_node = self.root - total_children = len(self.children_map.get(parent_path, [])) - currently_loaded = self.loaded_counts.get(parent_path, 0) + total_children = parent_node.total_children() + currently_loaded = parent_node.loaded_count if currently_loaded >= total_children: return # Everything already loaded @@ -246,76 +306,98 @@ def fetchMore(self, parent: QModelIndex) -> None: last_new_row = currently_loaded + to_load - 1 self.beginInsertRows(parent, first_new_row, last_new_row) - self.loaded_counts[parent_path] = currently_loaded + to_load + parent_node.loaded_count = currently_loaded + to_load self.endInsertRows() # --- helper functions --- - def build_hierarchy_from_index(self, pandas_index: pd.Index) -> dict[tuple, list[tuple]]: - children_map = defaultdict(list) + def build_node_hierarchy(self, pandas_index: pd.Index) -> None: + """ + Build the unified TreeNode hierarchy with all information combined: + - children relationships + - row indices + - loaded counts + - DataFrame positions + """ + self.node_map = {tuple(): self.root} # Convert index to frame once for all operations idx_df = pandas_index.to_frame(index=False) - - # Process each level + + # Create a mapping from full path to DataFrame position + path_to_position = {} + for df_pos, row_tuple in enumerate(idx_df.itertuples(index=False, name=None)): + path_to_position[row_tuple] = df_pos + + # Process each level to build the hierarchy for level in range(idx_df.shape[1]): # Get unique child paths at this level (as tuples) child_paths = idx_df.iloc[:, :level + 1].drop_duplicates() child_tuples = list(child_paths.itertuples(index=False, name=None)) - if level == 0: - # Root level - all children belong to empty tuple parent - children_map[tuple()] = child_tuples - else: - # Group children by their parent path - parent_paths = child_paths.iloc[:, :level] - parent_tuples = list(parent_paths.itertuples(index=False, name=None)) + for child_path in child_tuples: + if pd.isna(child_path[-1]): + continue # skip NaN children - # Build parent->children mapping efficiently with zip - for parent, child in zip(parent_tuples, child_tuples): - if pd.isna(child[-1]): - continue # skip NaN children - parent = tuple(val for val in parent if not pd.isna(val)) - - children_map[parent].append(child) + # Skip if we've already created this node + if child_path in self.node_map: + continue + + # Determine parent path + if level == 0: + parent_path = tuple() + else: + parent_path = tuple(val for val in child_path[:-1] if not pd.isna(val)) + + # Get or create parent node + parent_node = self.node_map.get(parent_path) + if parent_node is None: + parent_node = self.root + + # Check if this is a leaf node (full depth) + is_leaf = (level == idx_df.shape[1] - 1) + df_position = path_to_position.get(child_path, -1) if is_leaf else -1 + + # Create the child node + child_node = TreeNode(child_path, df_position) + + # Add child to parent + parent_node.add_child(child_node) + + # Store in node map + self.node_map[child_path] = child_node - return dict(children_map) - - def build_row_indices(self) -> None: - """Build a mapping of parent_path -> {child_path: row_index} for O(1) lookups.""" - self.row_indices = {} - for parent_path, children in self.children_map.items(): - self.row_indices[parent_path] = {child: idx for idx, child in enumerate(children)} + # Initialize loaded counts + if self.lazy: + # Load first chunk for each node + for node in self.node_map.values(): + node.loaded_count = min(self.chunk_size, node.total_children()) + else: + # All children loaded + for node in self.node_map.values(): + node.loaded_count = node.total_children() def reset_hierarchy(self, df: pd.DataFrame = None) -> None: df = df if df is not None else self.df self.layoutAboutToBeChanged.emit() - old_persistent_paths = [idx.internalPointer() for idx in self.persistentIndexList()] + old_persistent_indices = [(idx, idx.internalPointer()) for idx in self.persistentIndexList()] - self.children_map = self.build_hierarchy_from_index(df.index) - self.build_row_indices() + # Rebuild the node hierarchy + self.root = TreeNode(tuple()) + self.build_node_hierarchy(df.index) - # Reset loaded counts for lazy loading - self.loaded_counts = {} - if self.lazy: - # Load first chunk for each parent - for parent_path in self.children_map: - total = len(self.children_map[parent_path]) - self.loaded_counts[parent_path] = min(self.chunk_size, total) - else: - # All rows loaded - for parent_path, children in self.children_map.items(): - self.loaded_counts[parent_path] = len(children) - + # Update persistent indexes new_persistent = [] - for path, index in zip(old_persistent_paths, self.persistentIndexList()): - parent_path = path[:-1] - if parent_path in self.row_indices and path in self.row_indices[parent_path]: - row = self.row_indices[parent_path][path] - true_path = self.children_map[parent_path][row] - new_index = self.createIndex(row, index.column(), true_path) - new_persistent.append(new_index) + for old_index, old_node in old_persistent_indices: + if isinstance(old_node, TreeNode): + # Try to find the same path in the new hierarchy + new_node = self.node_map.get(old_node.path) + if new_node is not None: + new_index = self.createIndex(new_node.row_in_parent, old_index.column(), new_node) + new_persistent.append(new_index) + else: + new_persistent.append(QModelIndex()) else: new_persistent.append(QModelIndex()) @@ -436,7 +518,16 @@ def values_from_indices(self, key: str, indices: list[QModelIndex]): Returns: list: The list of values. """ - paths = {index.internalPointer() for index in indices if index.isValid()} - paths = [path for path in paths if len(path) == self.df.index.nlevels] # only leaf nodes - return self.df.loc[paths, key].tolist() + df_positions = [] + for index in indices: + if not index.isValid(): + continue + node = index.internalPointer() + if isinstance(node, TreeNode) and node.is_leaf: + df_positions.append(node.df_position) + + if not df_positions: + return [] + + return self.df.iloc[df_positions][key].tolist() From acf0930f7a8e9186a206f9bf0f4d3edddd80e355 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 10 Nov 2025 11:36:28 +0100 Subject: [PATCH 103/267] Add multiprocessing support for worker sub-process flow --- activity_browser/__main__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index 504bb79cc..386c4719e 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -1,3 +1,9 @@ +# Divert the program flow in worker sub-process as soon as possible, +# before importing heavy-weight modules. +if __name__ == '__main__': + import multiprocessing + multiprocessing.freeze_support() + import sys import os from importlib import metadata From 5c25fbfc4bce2cd5fef1d311b0870bf8a782ba68 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 10 Nov 2025 14:49:36 +0100 Subject: [PATCH 104/267] Move calculation setups to new TableView paradigm --- .../functional_unit_section.py | 150 ++++++++++-------- .../impact_category_section.py | 51 +++--- activity_browser/ui/core/tree_model.py | 26 ++- 3 files changed, 123 insertions(+), 104 deletions(-) diff --git a/activity_browser/app/pages/calculation_setup/functional_unit_section.py b/activity_browser/app/pages/calculation_setup/functional_unit_section.py index 19c3391ca..d29026d3e 100644 --- a/activity_browser/app/pages/calculation_setup/functional_unit_section.py +++ b/activity_browser/app/pages/calculation_setup/functional_unit_section.py @@ -1,11 +1,11 @@ -from qtpy import QtWidgets +from qtpy import QtWidgets, QtCore from qtpy.QtCore import Qt import bw2data as bd import pandas as pd -from activity_browser import app, app -from activity_browser.ui import widgets, icons, delegates +from activity_browser import app +from activity_browser.ui import widgets, icons, delegates, core from activity_browser.bwutils.commontasks import is_node_product @@ -16,8 +16,8 @@ def __init__(self, calculation_setup_name: str, parent=None): self.calculation_setup_name = calculation_setup_name self.calculation_setup = bd.calculation_setups.get(self.calculation_setup_name) - self.view = FunctionalUnitView() - self.model = FunctionalUnitModel() + self.view = FunctionalUnitView(self) + self.model = FunctionalUnitModel(parent=self) self.view.setModel(self.model) self.build_layout() @@ -30,7 +30,9 @@ def build_layout(self): def sync(self): try: self.calculation_setup = bd.calculation_setups[self.calculation_setup_name] - self.model.setDataFrame(self.build_df()) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) except KeyError: self.parent().close() self.parent().deleteLater() @@ -82,37 +84,25 @@ def build_df(self): return act_df[cols].reset_index(drop=True) -class FunctionalUnitView(widgets.ABTreeView): +class FunctionalUnitView(widgets.ABNewTreeView): defaultColumnDelegates = { "amount": delegates.AmountDelegate } class ContextMenu(widgets.ABMenu): menuSetup = [ - lambda m, p: m.add(app.actions.ActivityOpen, m.selected_processes, - text="Open process" if len(m.selected_processes) == 1 else "Open processes", - enable=len(m.selected_processes) > 0 + lambda m, p: m.add(app.actions.ActivityOpen, p.selected_processes(), + text="Open process" if len(p.selected_processes()) == 1 else "Open processes", + enable=len(p.selected_processes()) > 0 ), lambda m: m.addSeparator(), - lambda m, p: m.add(app.actions.CSDeleteFunctionalUnit, m.cs_name, m.selected_fus, - text="Delete Functional Unit" if len(m.selected_fus) == 1 else "Delete Functional Units", - enable=len(m.selected_fus) > 0 + lambda m, p: m.add(app.actions.CSDeleteFunctionalUnit, p.cs_name(), p.selected_row_indices(), + text="Delete Functional Unit" if len(p.selected_processes()) == 1 else "Delete Functional Units", + enable=len(p.selected_processes()) > 0 ), ] - - @property - def selected_fus(self): - return list(set([index.internalPointer().key() for index in self.parent().selectedIndexes()])) - - @property - def selected_processes(self): - return list(set([index.internalPointer()["_processor_key"] for index in self.parent().selectedIndexes()])) - - @property - def cs_name(self): - return self.parent().parent().calculation_setup_name - + def __init__(self, parent=None): super().__init__(parent) self.setAcceptDrops(True) @@ -127,11 +117,11 @@ def mouseDoubleClickEvent(self, event) -> None: event: The mouse double click event. """ index = self.indexAt(event.pos()) - if index.column() == 0: + if index.column() == 1: # Prevent action on amount column return super().mouseDoubleClickEvent(event) if self.selectedIndexes(): - activities = [index.internalPointer()["_processor_key"] for index in self.selectedIndexes()] + activities = self.model().values_from_indices("_processor_key", self.selectedIndexes()) app.actions.ActivityOpen.run(list(set(activities))) return None @@ -162,57 +152,87 @@ def dropEvent(self, event) -> None: app.actions.CSAddFunctionalUnit.run(cs_name, keys) - -class FunctionalUnitItem(widgets.ABDataItem): - def decorationData(self, col: int, key: str): - if key == "product" and self["_type"] == "waste": - return icons.qicons.waste - elif key == "product" and self["type"] == "processwithreferenceproduct": - return icons.qicons.processproduct - elif key == "product": - return icons.qicons.product - if key == "process": - return icons.qicons.process - return super().decorationData(col, key) - - def flags(self, col: int, key: str): + def selected_row_indices(self): + return [i.row() for i in super().selectedIndexes()] + + def cs_name(self): + return self.parent().calculation_setup_name + + def selected_processes(self): + return list(set(self.model().values_from_indices("_processor_key", self.selectedIndexes()))) + + +class FunctionalUnitModel(core.ABTreeModel): + """ + A model representing the data for the functional units. + """ + + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: """ - Returns the item flags for the given column and key. + Sets the data for the given index. Args: - col (int): The column index. - key (str): The key for which to return the flags. + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. Returns: - QtCore.Qt.ItemFlags: The item flags. + bool: True if the data was set successfully, False otherwise. """ - flags = super().flags(col, key) - if key in ["amount"]: - return flags | Qt.ItemFlag.ItemIsEditable - return flags + if role != Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False - def setData(self, col: int, key: str, value) -> bool: + if column_name == "amount": + cs_name = row.get("_cs_name") + app.actions.CSChangeFunctionalUnit.run(cs_name, index.row(), value) + return True + + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: """ - Sets the data for the given column and key. + Provides decoration data (icons) for the model. Args: - col (int): The column index. - key (str): The key for which to set the data. - value: The value to set. + index (QtCore.QModelIndex): The index for which to provide decoration data. Returns: - bool: True if the data was set successfully, False otherwise. + The decoration data (icon) for the index. """ - if key not in ["amount"]: - return False - - cs_name = self["_cs_name"] - index = self.key() - - app.actions.CSChangeFunctionalUnit.run(cs_name, index, value) + column_name = self.column_name(index) + + if column_name == "product": + product_type = self.get(index, "_type") + if product_type == "waste": + return icons.qicons.waste + elif product_type == "processwithreferenceproduct": + return icons.qicons.processproduct + else: + return icons.qicons.product + elif column_name == "process": + return icons.qicons.process + return None + + def indexEditable(self, index: QtCore.QModelIndex) -> bool: + """ + Returns whether the index is editable. + Args: + index (QtCore.QModelIndex): The index to check. + Returns: + bool: True if the index is editable, False otherwise. + """ + column_name = self.column_name(index) -class FunctionalUnitModel(widgets.ABItemModel): - dataItemClass = FunctionalUnitItem + if column_name == "amount": + return True + + return False diff --git a/activity_browser/app/pages/calculation_setup/impact_category_section.py b/activity_browser/app/pages/calculation_setup/impact_category_section.py index 1105405e6..c9db43f66 100644 --- a/activity_browser/app/pages/calculation_setup/impact_category_section.py +++ b/activity_browser/app/pages/calculation_setup/impact_category_section.py @@ -1,10 +1,11 @@ -from qtpy import QtWidgets +from qtpy import QtWidgets, QtCore +from qtpy.QtCore import Qt import bw2data as bd import pandas as pd from activity_browser import app -from activity_browser.ui import widgets, delegates +from activity_browser.ui import widgets, delegates, core class ImpactCategorySection(QtWidgets.QWidget): @@ -14,8 +15,8 @@ def __init__(self, calculation_setup_name: str, parent=None): self.calculation_setup_name = calculation_setup_name self.calculation_setup = bd.calculation_setups.get(self.calculation_setup_name) - self.view = ImpactCategoryView() - self.model = ImpactCategoryModel() + self.view = ImpactCategoryView(self) + self.model = ImpactCategoryModel(parent=self) self.view.setModel(self.model) self.build_layout() @@ -28,7 +29,9 @@ def build_layout(self): def sync(self): try: self.calculation_setup = bd.calculation_setups[self.calculation_setup_name] - self.model.setDataFrame(self.build_df()) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) except KeyError: self.parent().close() self.parent().deleteLater() @@ -38,30 +41,33 @@ def build_df(self): df = pd.DataFrame(data, columns=["name", "unit", "num_cfs"]) df["name"] = self.calculation_setup.get("ia", []) + df["_cs_name"] = self.calculation_setup_name - cols = ["name", "unit", "num_cfs"] + cols = ["name", "unit", "num_cfs", "_cs_name"] return df[cols] -class ImpactCategoryView(widgets.ABTreeView): +class ImpactCategoryView(widgets.ABNewTreeView): defaultColumnDelegates = { "name": delegates.StringDelegate } - class ContextMenu(QtWidgets.QMenu): - def __init__(self, pos, view: "ImpactCategoryView"): - super().__init__(view) - cs_name = view.parent().calculation_setup_name + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m, p: m.add(app.actions.CSDeleteImpactCategory, m.cs_name, m.selected_ics, + text="Delete Impact Category" if len(m.selected_ics) == 1 else "Delete Impact Categories", + enable=len(m.selected_ics) > 0 + ), + ] - if not view.selectedIndexes(): - return + @property + def selected_ics(self): + return self.parent().model().values_from_indices("name", self.parent().selectedIndexes()) - indices = [index.internalPointer().key() for index in view.selectedIndexes()] - - self.delete_ic_action = app.actions.CSDeleteImpactCategory.get_QAction(cs_name, indices) - print(self.delete_ic_action.text()) - self.addAction(self.delete_ic_action) + @property + def cs_name(self): + return self.parent().parent().calculation_setup_name def __init__(self, parent=None): super().__init__(parent) @@ -83,9 +89,8 @@ def dropEvent(self, event) -> None: app.actions.CSAddImpactCategory.run(cs_name, method_names) -class ImpactCategoryItem(widgets.ABDataItem): +class ImpactCategoryModel(core.ABTreeModel): + """ + A model representing the data for the impact categories. + """ pass - - -class ImpactCategoryModel(widgets.ABItemModel): - dataItemClass = ImpactCategoryItem diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 5eb2f7b1c..63e5c2ee3 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -218,6 +218,9 @@ def fontData(self, index: QModelIndex) -> any: #--- flag overrides --- def flags(self, index): + if not index.isValid(): + return Qt.ItemFlag.NoItemFlags + flags = Qt.ItemFlag.NoItemFlags if self.indexEnabled(index): flags |= Qt.ItemFlag.ItemIsEnabled @@ -377,10 +380,7 @@ def build_node_hierarchy(self, pandas_index: pd.Index) -> None: node.loaded_count = node.total_children() def reset_hierarchy(self, df: pd.DataFrame = None) -> None: - df = df if df is not None else self.df - - self.layoutAboutToBeChanged.emit() - + df = df if df is not None else self.df old_persistent_indices = [(idx, idx.internalPointer()) for idx in self.persistentIndexList()] # Rebuild the node hierarchy @@ -432,6 +432,7 @@ def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) - def filter(self, key: str = None, query: str = None) -> None: """Filter the DataFrame based on a simple substring match across all columns.""" + self.layoutAboutToBeChanged.emit() if query is not None and key is not None: self.df_query[key] = query @@ -439,18 +440,7 @@ def filter(self, key: str = None, query: str = None) -> None: filtered_df = self.df.query(pandas_query) self.reset_hierarchy(filtered_df) - - def quick_filter(self, substring: str) -> None: - """Quick filter rows containing the substring in any column.""" - if not substring: - self.filter("index == index") # reset filter - return - - query = " or ".join( - f"`{col}`.astype('string').str.contains({substring!r}, case=False, na=False, regex=False)" - for col in self.df.columns - ) - self.filter(query) + self.layoutChanged.emit() def set_dataframe(self, df: pd.DataFrame) -> None: self.beginResetModel() @@ -466,6 +456,7 @@ def group(self, columns: list[str]) -> None: Unpacks columns containing iterables (lists, tuples, sets) by spreading them into separate columns that become separate levels in the multiindex. """ + self.layoutAboutToBeChanged.emit() df = self.df[columns].copy() # Build the list of columns for the new index, unpacking iterables @@ -500,12 +491,15 @@ def group(self, columns: list[str]) -> None: self.df = self.df.set_index(new_index) self.reset_hierarchy() + self.layoutChanged.emit() def ungroup(self) -> None: """Ungroup the DataFrame by resetting the index.""" + self.layoutAboutToBeChanged.emit() self.df.index = pd.MultiIndex.from_arrays([range(len(self.df))], names=[f"index"]) self.df.index.name = "index" self.reset_hierarchy() + self.layoutChanged.emit() def values_from_indices(self, key: str, indices: list[QModelIndex]): """ From 1997bb377e0994bd6f196b766e20710ca2e8312f Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 10 Nov 2025 16:35:22 +0100 Subject: [PATCH 105/267] Add a simple tooltip to the ProductModel --- activity_browser/app/panes/database_products.py | 16 ++++++++++++++++ activity_browser/ui/core/tree_model.py | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index f777f206a..547294a7f 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -353,6 +353,22 @@ def decorationData(self, index: QtCore.QModelIndex) -> any: if node_type in NODETYPES["biosphere"]: return icons.qicons.biosphere return icons.qicons.process + + def toolTipData(self, index: QtCore.QModelIndex) -> str: + column_name = self.column_name(index) + if column_name not in ["name", "product"]: + return None + + row = self.row(index) + + html_tooltip = f""" + {row.get('product')}
+ {row.get('name')}
+
+ {row.get('unit')} | {row.get('location')} | {row.get('type')} + """ + + return html_tooltip def mimeData(self, indices: list[QtCore.QModelIndex]): """ diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 63e5c2ee3..c0c3c5392 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -175,6 +175,8 @@ def data(self, index: QModelIndex, role: int = Qt.DisplayRole): return self.decorationData(index) elif role == Qt.FontRole: return self.fontData(index) + elif role == Qt.ToolTipRole: + return self.toolTipData(index) return None @@ -216,6 +218,9 @@ def decorationData(self, index: QModelIndex) -> any: def fontData(self, index: QModelIndex) -> any: return None + def toolTipData(self, index: QModelIndex) -> any: + return None + #--- flag overrides --- def flags(self, index): if not index.isValid(): From 8c976cae6cc3bd4336f5690b04b73382610e195c Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 11 Nov 2025 12:34:09 +0100 Subject: [PATCH 106/267] Move new parameters page to new table --- .../app/pages/parameters/parameters_new.py | 543 ++++++++---------- 1 file changed, 252 insertions(+), 291 deletions(-) diff --git a/activity_browser/app/pages/parameters/parameters_new.py b/activity_browser/app/pages/parameters/parameters_new.py index e2799d982..ba5d8cc2c 100644 --- a/activity_browser/app/pages/parameters/parameters_new.py +++ b/activity_browser/app/pages/parameters/parameters_new.py @@ -1,13 +1,13 @@ -from qtpy import QtWidgets, QtCore - +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt import pandas as pd import bw2data as bd from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, ParameterizedExchange from bw2data.backends import ExchangeDataset -from activity_browser import app, app -from activity_browser.ui import widgets, icons, delegates -from activity_browser.bwutils.commontasks import refresh_parameter, refresh_node, database_is_locked +from activity_browser import app +from activity_browser.ui import widgets, icons, delegates, core +from activity_browser.bwutils.commontasks import refresh_parameter, database_is_locked from activity_browser.bwutils.utils import Parameter @@ -35,12 +35,12 @@ def __init__(self, parent=None): super().__init__(parent) # Parameters tree view - self.model = ProjectParametersModel(self.build_df(), self) + self.model = ProjectParametersModel(parent=self) self.view = ProjectParametersView() self.view.setModel(self.model) # Parameterized exchanges table view - self.exchanges_model = ParameterizedExchangesModel(self.build_exchanges_df(), self) + self.exchanges_model = ParameterizedExchangesModel(parent=self) self.exchanges_view = ParameterizedExchangesView() self.exchanges_view.setModel(self.exchanges_model) @@ -99,8 +99,15 @@ def sync(self): """ Synchronizes the widget with the current state of parameters. """ - self.model.setDataFrame(self.build_df()) - self.exchanges_model.setDataFrame(self.build_exchanges_df()) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) + self.model.group(["_scope"]) + self.view.expandAll() + + exchanges_df = self.build_exchanges_df() + exchanges_df.reset_index(drop=True, inplace=True) + self.exchanges_model.set_dataframe(exchanges_df) self.view.expandAll() @@ -133,7 +140,55 @@ def build_df(self) -> pd.DataFrame: translated.append(row) columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_database", "_group"] - return pd.DataFrame(translated, columns=columns) + df = pd.DataFrame(translated, columns=columns) + df["_is_new"] = False + + # Add "New parameter..." placeholders + new_rows = [] + + # Add for project + new_rows.append({ + "name": "New parameter...", + "_scope": "Current project", + "_group": "project", + "_param_type": "project", + "_is_new": True, + }) + + # Add for each database + for db_name in sorted(bd.databases.list): + if not bd.databases[db_name].get("read_only", True): + new_rows.append({ + "name": "New parameter...", + "_scope": f"Database: {db_name}", + "_database": db_name, + "_group": db_name, + "_param_type": "database", + "_is_new": True, + }) + + # Add for each activity group + activity_params = df[df._scope.str.startswith("Group: ", na=False)] + groups = activity_params._group.unique() if len(activity_params) > 0 else [] + for group_name in sorted(groups): + group_data = activity_params[activity_params._group == group_name] + db_name = group_data.iloc[0]._database if len(group_data) > 0 else None + if db_name and db_name in bd.databases and not bd.databases[db_name].get("read_only", True): + new_rows.append({ + "name": "New parameter...", + "_scope": f"Group: {group_name}", + "_database": db_name, + "_group": group_name, + "_param_type": "activity", + "_is_new": True, + }) + + # Append new rows to dataframe + if new_rows: + new_df = pd.DataFrame(new_rows) + df = pd.concat([df, new_df], ignore_index=True) + + return df def _parameter_to_row(self, param, scope_label: str, database: str = None) -> dict: """ @@ -220,7 +275,7 @@ def build_exchanges_df(self) -> pd.DataFrame: return pd.DataFrame(translated, columns=columns) -class ProjectParametersView(widgets.ABTreeView): +class ProjectParametersView(widgets.ABNewTreeView): """ A view that displays the project parameters in a tree structure. @@ -235,12 +290,9 @@ class ProjectParametersView(widgets.ABTreeView): "uncertainty": delegates.UncertaintyDelegate, } - class ContextMenu(QtWidgets.QMenu): + class ContextMenu(widgets.ABMenu): """ A context menu for the ProjectParametersView. - - Attributes: - del_param_action (QAction): The action to delete a parameter. """ def __init__(self, pos, view: "ProjectParametersView"): @@ -254,264 +306,162 @@ def __init__(self, pos, view: "ProjectParametersView"): super().__init__(view) index = view.indexAt(pos) - if index.isValid() and isinstance(index.internalPointer(), ProjectParametersItem): - item = index.internalPointer() - param = item.parameter.to_peewee_model() - self.del_param_action = app.actions.ParameterDelete().get_QAction(param) - if not param.is_deletable() or param.name == "dummy_parameter": - self.del_param_action.setEnabled(False) - self.addAction(self.del_param_action) - - -class ProjectParametersItem(widgets.ABDataItem): + if index.isValid() and not view.model().isBranchNode(index): + row = view.model().row(index) + if row is not None and not row.get("_is_new"): + parameter = row.get("_parameter") + if parameter: + param = refresh_parameter(parameter).to_peewee_model() + self.del_param_action = app.actions.ParameterDelete().get_QAction(param) + if not param.is_deletable() or param.name == "dummy_parameter": + self.del_param_action.setEnabled(False) + self.addAction(self.del_param_action) + + +class ProjectParametersModel(core.ABTreeModel): """ - An item representing a parameter in the tree view. + A model representing the data for all project parameters. """ - @property - def scoped_parameters(self) -> dict[str, Parameter]: - """ - Returns the parameters in scope of this item's parameter. - - Returns: - dict: The parameters in scope. - """ - from activity_browser.bwutils.commontasks import parameters_in_scope - return parameters_in_scope(parameter=self["_parameter"]) - - @property - def parameter(self) -> Parameter: - """ - Returns the parameter associated with this item. - - Returns: - Parameter: The current parameter. - """ - return refresh_parameter(self["_parameter"]) - - def flags(self, col: int, key: str): + def __init__(self, parent=None): """ - Returns the item flags for the given column and key. + Initializes the ProjectParametersModel. Args: - col (int): The column index. - key (str): The key for which to return the flags. - - Returns: - QtCore.Qt.ItemFlags: The item flags. + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. """ - flags = super().flags(col, key) - - # Allow editing for all parameters except those in locked databases - database = self["_database"] - if database and database_is_locked(database): - return flags - - if key in ["amount", "formula", "uncertainty", "name", "comment"]: - return flags | QtCore.Qt.ItemFlag.ItemIsEditable - return flags + super().__init__(df=pd.DataFrame(), parent=parent) - def setData(self, col: int, key: str, value) -> bool: + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: """ - Sets the data for the given column and key. + Sets the data for the given index. Args: - col (int): The column index. - key (str): The key for which to set the data. + index (QtCore.QModelIndex): The index to set data for. value: The value to set. + role (int): The role for which to set the data. Returns: bool: True if the data was set successfully, False otherwise. """ - if key in ["amount", "formula", "name", "comment"]: - app.actions.ParameterModify.run(self.parameter, key, value) + if role != Qt.ItemDataRole.EditRole: + return False - return False + column_name = self.column_name(index) + row = self.row(index) - def decorationData(self, col, key): - """ - Provides decoration data for the item. + if row is None: + return False - Args: - col: The column index. - key: The key for which to provide decoration data. + # Handle "New parameter..." rows + if row.get("_is_new"): + if column_name != "name" or value == "": + return False - Returns: - The decoration data for the item. - """ - if key not in ["amount"]: - return + parameter = Parameter( + name=value, + group=row.get("_group"), + param_type=row.get("_param_type") + ) - if key == "amount": - if pd.isna(self["formula"]) or self["formula"] is None or self["formula"] == "": - return icons.qicons.empty # empty icon to align the values - return icons.qicons.parameterized + app.actions.ParameterNewFromParameter.run(parameter) + return True + + # Handle regular parameter edits + parameter = row.get("_parameter") + if parameter is None: + return False + if column_name in ["amount", "formula", "name", "comment"]: + parameter = refresh_parameter(parameter) + app.actions.ParameterModify.run(parameter, column_name, value) -class NewProjectParametersItem(widgets.ABDataItem): - """ - An item representing a new parameter placeholder in the tree view. - """ + return False - def flags(self, col: int, key: str): + def decorationData(self, index: QtCore.QModelIndex) -> any: """ - Returns the item flags for the given column and key. + Provides decoration data for the model. Args: - col (int): The column index. - key (str): The key for which to return the flags. + index (QtCore.QModelIndex): The index for which to provide decoration data. Returns: - QtCore.Qt.ItemFlags: The item flags. + The decoration data for the index. """ - flags = super().flags(col, key) - if key == "name": - return flags | QtCore.Qt.ItemFlag.ItemIsEditable - return flags + column_name = self.column_name(index) + + if column_name == "amount": + return icons.qicons.empty if pd.isna(self.get(index, "formula")) else icons.qicons.parameterized - def fontData(self, col: int, key: str): + return None + + def fontData(self, index: QtCore.QModelIndex) -> any: """ - Returns the font data for the given column and key. + Provides font data for the model. Args: - col (int): The column index. - key (str): The key for which to return the font data. + index (QtCore.QModelIndex): The index for which to provide font data. Returns: - QtGui.QFont: The font data. + QtGui.QFont: The font data for the index. """ - font = super().fontData(col, key) - font.setWeight(font.Weight.ExtraLight) - return font + if self.get(index, "_is_new"): + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.ExtraLight) + return font + + return None - def setData(self, col: int, key: str, value) -> bool: + def indexEditable(self, index: QtCore.QModelIndex) -> bool: """ - Sets the data for the given column and key. + Returns whether the index is editable. Args: - col (int): The column index. - key (str): The key for which to set the data. - value: The value to set. + index (QtCore.QModelIndex): The index to check. Returns: - bool: True if the data was set successfully, False otherwise. + bool: True if the index is editable, False otherwise. """ - if key != "name" or value == "": - return False - - parameter = Parameter( - name=value, - group=self["_group"], - param_type=self["_param_type"] - ) - - app.actions.ParameterNewFromParameter.run(parameter) - return True - - -class ProjectParametersModel(widgets.ABItemModel): - """ - A model representing the data for all project parameters. + column_name = self.column_name(index) - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = ProjectParametersItem + # Check if database is locked + database = self.get(index, "_database") + if not pd.isna(database) and database_is_locked(database): + return False - def __init__(self, dataframe, parent=None): - """ - Initializes the ProjectParametersModel. + # Allow editing for specific columns + if column_name in ["formula", "uncertainty", "name", "comment"]: + return True + + if column_name == "amount" and not self.get(index, "formula"): + return True - Args: - dataframe (pd.DataFrame): The DataFrame containing the parameters data. - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(parent, dataframe) + return False - def createItems(self, dataframe=None) -> list[widgets.ABAbstractItem]: + def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: """ - Creates items from the given DataFrame, organized by scope. + Returns the parameters in scope of the parameter at the given index. Args: - dataframe (pd.DataFrame, optional): The DataFrame containing the parameters data. Defaults to None. + index (QtCore.QModelIndex): The index to get scoped parameters for. Returns: - list[widgets.ABAbstractItem]: The list of created items. + dict: The parameters in scope. """ - if dataframe is None: - dataframe = self.dataframe - - items = [] - - # Project parameters - project_branch = self.branchItemClass("Current project") - project_params = dataframe[dataframe._scope == "Current project"] - for index, data in project_params.to_dict(orient="index").items(): - self.dataItemClass(index, data, project_branch) - - # Add "New parameter..." placeholder for project - NewProjectParametersItem(None, { - "name": "New parameter...", - "_group": "project", - "_param_type": "project" - }, project_branch) - - items.append(project_branch) - - # Database parameters - grouped by database - # Get all databases, not just those with parameters - all_databases = set(bd.databases.list) - database_params = dataframe[dataframe._scope.str.startswith("Database: ", na=False)] - databases_with_params = set(database_params._database.unique() if len(database_params) > 0 else []) + from activity_browser.bwutils.commontasks import parameters_in_scope - # Combine databases with and without parameters - all_databases_sorted = sorted(all_databases) - - for db_name in all_databases_sorted: - db_branch = self.branchItemClass(f"Database: {db_name}") - - # Add existing parameters for this database - if db_name in databases_with_params: - db_data = database_params[database_params._database == db_name] - for index, data in db_data.to_dict(orient="index").items(): - self.dataItemClass(index, data, db_branch) - - # Add "New parameter..." placeholder if database is not read-only - if not bd.databases[db_name].get("read_only", True): - NewProjectParametersItem(None, { - "name": "New parameter...", - "_group": db_name, - "_param_type": "database" - }, db_branch) - - items.append(db_branch) - - # Activity parameters - grouped by group - activity_params = dataframe[dataframe._scope.str.startswith("Group: ", na=False)] - groups = activity_params._group.unique() if len(activity_params) > 0 else [] + row = self.row(index) + if row is None: + return {} - for group_name in sorted(groups): - group_branch = self.branchItemClass(f"Group: {group_name}") - group_data = activity_params[activity_params._group == group_name] - - for index, data in group_data.to_dict(orient="index").items(): - self.dataItemClass(index, data, group_branch) - - # Add "New parameter..." placeholder if database is not read-only - db_name = group_data.iloc[0]._database if len(group_data) > 0 else None - if db_name and db_name in bd.databases and not bd.databases[db_name].get("read_only", True): - NewProjectParametersItem(None, { - "name": "New parameter...", - "_group": group_name, - "_param_type": "activity" - }, group_branch) + parameter = row.get("_parameter") + if parameter is None: + return {} - items.append(group_branch) + return parameters_in_scope(parameter=parameter) - return items - -class ParameterizedExchangesView(widgets.ABTreeView): +class ParameterizedExchangesView(widgets.ABNewTreeView): """ A view that displays parameterized exchanges in a tree structure. @@ -530,7 +480,7 @@ class ParameterizedExchangesView(widgets.ABTreeView): "uncertainty": delegates.UncertaintyDelegate, } - class ContextMenu(QtWidgets.QMenu): + class ContextMenu(widgets.ABMenu): """ A context menu for the ParameterizedExchangesView. """ @@ -545,121 +495,132 @@ def __init__(self, pos, view: "ParameterizedExchangesView"): super().__init__(view) index = view.indexAt(pos) - if index.isValid() and isinstance(index.internalPointer(), ParameterizedExchangesItem): - item = index.internalPointer() - - # Open activity action - open_action = app.actions.ActivityOpen.get_QAction([item["_output_key"]]) - open_action.setText("Open activity") - self.addAction(open_action) - - -class ParameterizedExchangesItem(widgets.ABDataItem): + if index.isValid() and not view.model().isBranchNode(index): + row = view.model().row(index) + if row is not None: + output_key = row.get("_output_key") + if output_key: + # Open activity action + open_action = app.actions.ActivityOpen.get_QAction([output_key]) + open_action.setText("Open activity") + self.addAction(open_action) + + +class ParameterizedExchangesModel(core.ABTreeModel): """ - An item representing a parameterized exchange in the tree view. + A model representing the data for parameterized exchanges. """ - @property - def exchange(self): + def __init__(self, parent=None): """ - Returns the exchange associated with this item. + Initializes the ParameterizedExchangesModel. - Returns: - The exchange associated with the item. + Args: + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. """ - return self["_exchange"] + super().__init__(df=pd.DataFrame(), parent=parent) - @property - def scoped_parameters(self) -> dict[str, Parameter]: + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: """ - Returns the parameters in scope of this exchange. + Sets the data for the given index. + + Args: + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. Returns: - dict: The parameters in scope. + bool: True if the data was set successfully, False otherwise. """ - from activity_browser.bwutils.commontasks import parameters_in_scope - return parameters_in_scope(node=self["_exchange"].output) + if role != Qt.ItemDataRole.EditRole: + return False - def flags(self, col: int, key: str): - """ - Returns the item flags for the given column and key. + column_name = self.column_name(index) + row = self.row(index) - Args: - col (int): The column index. - key (str): The key for which to return the flags. + if row is None: + return False - Returns: - QtCore.Qt.ItemFlags: The item flags. - """ - flags = super().flags(col, key) + exchange = row.get("_exchange") + if exchange is None: + return False - # Check if database is locked - if database_is_locked(self.exchange.output["database"]): - return flags + if column_name in ["amount", "formula", "comment"]: + if column_name == "formula" and not str(value).strip(): + # Remove formula if empty + app.actions.ExchangeFormulaRemove.run([exchange]) + return True - # Allow editing for specific keys - if key in ["amount", "formula", "comment"]: - return flags | QtCore.Qt.ItemFlag.ItemIsEditable + app.actions.ExchangeModify.run(exchange, {column_name.lower(): value}) + return True - return flags + return False - def setData(self, col: int, key: str, value) -> bool: + def decorationData(self, index: QtCore.QModelIndex) -> any: """ - Sets the data for the given column and key. + Provides decoration data for the model. Args: - col (int): The column index. - key (str): The key for which to set the data. - value: The value to set. + index (QtCore.QModelIndex): The index for which to provide decoration data. Returns: - bool: True if the data was set successfully, False otherwise. + The decoration data for the index. """ - if key in ["amount", "formula", "comment"]: - if key == "formula" and not str(value).strip(): - app.actions.ExchangeFormulaRemove.run([self.exchange]) - return True + column_name = self.column_name(index) - app.actions.ExchangeModify.run(self.exchange, {key.lower(): value}) - return True + if column_name == "amount": + formula = self.get(index, "formula") + if pd.isna(formula) or formula is None or formula == "": + return icons.qicons.edit + return icons.qicons.parameterized - return False + return None - def decorationData(self, col, key): + def indexEditable(self, index: QtCore.QModelIndex) -> bool: """ - Provides decoration data for the item. + Returns whether the index is editable. Args: - col: The column index. - key: The key for which to provide decoration data. + index (QtCore.QModelIndex): The index to check. Returns: - The decoration data for the item. + bool: True if the index is editable, False otherwise. """ - if key not in ["amount"]: - return + column_name = self.column_name(index) + row = self.row(index) - if key == "amount": - if pd.isna(self["formula"]) or self["formula"] is None or self["formula"] == "": - return icons.qicons.empty # empty icon to align the values - return icons.qicons.parameterized + if row is None: + return False + # Check if database is locked + exchange = row.get("_exchange") + if exchange and database_is_locked(exchange.output["database"]): + return False -class ParameterizedExchangesModel(widgets.ABItemModel): - """ - A model representing the data for parameterized exchanges. + # Allow editing for specific columns + if column_name in ["amount", "formula", "comment"]: + return True - Attributes: - dataItemClass (type): The class of the data items. - """ - dataItemClass = ParameterizedExchangesItem + return False - def __init__(self, dataframe, parent=None): + def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: """ - Initializes the ParameterizedExchangesModel. + Returns the parameters in scope of the exchange at the given index. Args: - dataframe (pd.DataFrame): The DataFrame containing the exchanges data. - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + index (QtCore.QModelIndex): The index to get scoped parameters for. + + Returns: + dict: The parameters in scope. """ - super().__init__(parent, dataframe) + from activity_browser.bwutils.commontasks import parameters_in_scope + + row = self.row(index) + if row is None: + return {} + + exchange = row.get("_exchange") + if exchange is None: + return {} + + return parameters_in_scope(node=exchange.output) From be53a3f218ab5185295339fd37d2bdcb9fd51586 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 11 Nov 2025 13:31:46 +0100 Subject: [PATCH 107/267] Update the formula editors --- .../app/pages/parameters/parameters_new.py | 4 +- activity_browser/ui/widgets/formula_edit.py | 37 ++++++++++++++----- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/activity_browser/app/pages/parameters/parameters_new.py b/activity_browser/app/pages/parameters/parameters_new.py index ba5d8cc2c..636b0c2e0 100644 --- a/activity_browser/app/pages/parameters/parameters_new.py +++ b/activity_browser/app/pages/parameters/parameters_new.py @@ -111,9 +111,9 @@ def sync(self): self.view.expandAll() - self.view.resizeColumnToContents(0) - self.view.resizeColumnToContents(2) + self.view.resizeColumnToContents(1) self.view.resizeColumnToContents(3) + self.view.resizeColumnToContents(4) def build_df(self) -> pd.DataFrame: """ diff --git a/activity_browser/ui/widgets/formula_edit.py b/activity_browser/ui/widgets/formula_edit.py index 7a9efac29..0a536ff37 100644 --- a/activity_browser/ui/widgets/formula_edit.py +++ b/activity_browser/ui/widgets/formula_edit.py @@ -290,28 +290,45 @@ def get_cursor_position_from_x(self, x): x_offset = x - self.padding + self.scroll_offset cursor_pos = len(self.text) - for i in range(len(self.text)): - if font_metrics.horizontalAdvance(self.text[:i]) > x_offset: - cursor_pos = i - break + for i in range(len(self.text) + 1): + char_x = font_metrics.horizontalAdvance(self.text[:i]) + if i < len(self.text): + next_char_x = font_metrics.horizontalAdvance(self.text[:i + 1]) + mid_point = (char_x + next_char_x) / 2 + if x_offset < mid_point: + cursor_pos = i + break + else: + # Past the end of the text + if x_offset >= char_x: + cursor_pos = i + break return cursor_pos def mousePressEvent(self, event): """Handles mouse click events to set cursor position and start selection.""" - if 10 <= event.x() <= 390 and 10 <= event.y() <= 40: + if self.rect().contains(event.pos()): self.cursor_pos = self.get_cursor_position_from_x(event.x()) - self.selection_start = self.cursor_pos # Start selection + self.selection_start = None # Clear selection initially self.selection_end = None # Reset end position self.dragging = True # Start dragging self.adjust_scroll() - self.update() + self.cursor_visible = True # Show cursor immediately + self.update() # Force immediate redraw + self.timer.stop() # Stop the timer + self.timer.start(500) # Restart blink timer def mouseMoveEvent(self, event): """Handles mouse dragging for text selection.""" if self.dragging: - self.selection_end = self.get_cursor_position_from_x(event.x()) - self.cursor_pos = self.selection_end + new_pos = self.get_cursor_position_from_x(event.x()) + # Start selection on first move if not already started + if self.selection_start is None and new_pos != self.cursor_pos: + self.selection_start = self.cursor_pos + if self.selection_start is not None: + self.selection_end = new_pos + self.cursor_pos = new_pos self.adjust_scroll() self.update() @@ -361,7 +378,7 @@ def paint_text(self, painter: QPainter): if not painter.pen() == Qt.NoPen: pass - if token_type == "NUMBER": + elif token_type == "NUMBER": painter.setPen(Colors.number) elif token_type in ["SQSTRING", "DQSTRING"]: painter.setPen(Colors.string) From ced3357f56d72d62884177a7f61441355b2300bf Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 12 Nov 2025 09:12:06 +0100 Subject: [PATCH 108/267] Settings stuff --- activity_browser/__main__.py | 1 + activity_browser/app/__init__.py | 2 + .../actions/project/project_new_template.py | 7 +- activity_browser/app/menu_bar.py | 5 +- activity_browser/app/pages/__init__.py | 1 + activity_browser/app/pages/settings/README.md | 176 ++++++++++++++++ .../app/pages/settings/__init__.py | 5 + .../app/pages/settings/appearance.py | 70 +++++++ activity_browser/app/pages/settings/base.py | 39 ++++ .../app/pages/settings/settings_page.py | 162 +++++++++++++++ .../app/pages/settings/startup.py | 194 ++++++++++++++++++ activity_browser/app/panes/project_manager.py | 5 +- activity_browser/bwutils/commontasks.py | 36 +++- .../ecospold2biosphereimporter.py | 17 +- activity_browser/bwutils/filesystem.py | 25 +++ activity_browser/bwutils/settings.py | 77 +++++++ activity_browser/info.py | 54 +---- activity_browser/mod/bw2io/__init__.py | 6 +- .../bw2io/importers/ecospold2_biosphere.py | 17 +- activity_browser/ui/web/base.py | 5 +- activity_browser/ui/web/tree_navigator.py | 12 +- activity_browser/ui/web/webutils.py | 6 +- activity_browser/ui/widgets/__init__.py | 2 +- activity_browser/ui/widgets/plot.py | 4 +- activity_browser/utils.py | 92 --------- 25 files changed, 847 insertions(+), 173 deletions(-) create mode 100644 activity_browser/app/pages/settings/README.md create mode 100644 activity_browser/app/pages/settings/__init__.py create mode 100644 activity_browser/app/pages/settings/appearance.py create mode 100644 activity_browser/app/pages/settings/base.py create mode 100644 activity_browser/app/pages/settings/settings_page.py create mode 100644 activity_browser/app/pages/settings/startup.py create mode 100644 activity_browser/bwutils/filesystem.py create mode 100644 activity_browser/bwutils/settings.py delete mode 100644 activity_browser/utils.py diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index 386c4719e..6b9dc6d06 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -97,6 +97,7 @@ def load_layout(self): central_widget = CentralTabWidget(application.main_window) central_widget.addTab(pages.WelcomePage(), "Welcome") central_widget.addTab(pages.ParametersPage(), "Parameters") + central_widget.addTab(pages.SettingsPage(), "Settings") application.main_window.setCentralWidget(central_widget) diff --git a/activity_browser/app/__init__.py b/activity_browser/app/__init__.py index b2b4db6d2..c97dbeeb3 100644 --- a/activity_browser/app/__init__.py +++ b/activity_browser/app/__init__.py @@ -3,10 +3,12 @@ from activity_browser.ui.core.application import ABApplication from activity_browser.bwutils.metadata import MetaDataStore +from activity_browser.bwutils.settings import Settings from .main_window import MainWindow application = ABApplication() metadata = MetaDataStore() +settings = Settings() # modules dependent on application instance from .signalling import ABSignals diff --git a/activity_browser/app/actions/project/project_new_template.py b/activity_browser/app/actions/project/project_new_template.py index 0c07bae3e..936d2d8db 100644 --- a/activity_browser/app/actions/project/project_new_template.py +++ b/activity_browser/app/actions/project/project_new_template.py @@ -4,7 +4,8 @@ import bw2data as bd from bw2io import backup -from activity_browser import app, utils +from activity_browser import app +from activity_browser.bwutils.commontasks import get_templates from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.core.threading import ABThread @@ -26,7 +27,7 @@ class ProjectNewFromTemplate(ABAction): @exception_dialogs def run(template_key: str): - if template_key not in utils.get_templates(): + if template_key not in get_templates(): raise ValueError(f"Template key '{template_key}' not found.") name, ok = QtWidgets.QInputDialog.getText( @@ -62,7 +63,7 @@ def run(template_key: str): # setup the import thread = ImportThread(app.application) - setattr(thread, "path", utils.get_templates()[template_key]) + setattr(thread, "path", get_templates()[template_key]) setattr(thread, "project_name", name) thread.finished.connect(lambda: progress.deleteLater()) diff --git a/activity_browser/app/menu_bar.py b/activity_browser/app/menu_bar.py index c70241f0d..eb3af9300 100644 --- a/activity_browser/app/menu_bar.py +++ b/activity_browser/app/menu_bar.py @@ -5,7 +5,8 @@ from qtpy import QtGui, QtWidgets from qtpy.QtCore import QSize, QUrl -from activity_browser import app, utils, app +from activity_browser import app, app +from activity_browser.bwutils.commontasks import get_templates from ..ui.icons import qicons @@ -94,7 +95,7 @@ def __init__(self, parent=None): self.actions = {} - for key in utils.get_templates(): + for key in get_templates(): action = app.actions.ProjectNewFromTemplate.get_QAction(key) action.setText(key) self.actions[key] = action diff --git a/activity_browser/app/pages/__init__.py b/activity_browser/app/pages/__init__.py index bbded7e0a..b8e34b0ad 100644 --- a/activity_browser/app/pages/__init__.py +++ b/activity_browser/app/pages/__init__.py @@ -5,3 +5,4 @@ from .lca_results import LCAResultsPage from .parameters import ParametersPage from .metadatastore import MetaDataStorePage +from .settings import SettingsPage, BaseSettingsChapter diff --git a/activity_browser/app/pages/settings/README.md b/activity_browser/app/pages/settings/README.md new file mode 100644 index 000000000..2fee9d01d --- /dev/null +++ b/activity_browser/app/pages/settings/README.md @@ -0,0 +1,176 @@ +# Settings Module + +This module contains the settings page and its chapters. + +## Structure + +``` +settings/ +├── __init__.py # Module exports +├── settings_page.py # Main SettingsPage class +├── base.py # BaseSettingsChapter (base class for all chapters) +├── startup.py # StartupSettingsChapter +├── appearance.py # AppearanceSettingsChapter +└── README.md # This file +``` + +## Adding a New Chapter + +### Step 1: Create a new chapter file + +Create a new file in this directory, e.g., `my_chapter.py`: + +```python +# -*- coding: utf-8 -*- +from loguru import logger +from qtpy import QtWidgets + +from activity_browser.settings import ab_settings +from .base import BaseSettingsChapter + + +class MySettingsChapter(BaseSettingsChapter): + """Chapter for my settings.""" + + def __init__(self, parent=None): + super().__init__(parent) + + # Create your widgets + self.my_widget = QtWidgets.QLineEdit() + + self.build_layout() + self.connect_signals() + + def connect_signals(self): + """Connect signals for change tracking.""" + self.my_widget.textChanged.connect(self.changed.emit) + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Create your UI + group = QtWidgets.QGroupBox("My Settings") + group_layout = QtWidgets.QGridLayout() + group_layout.addWidget(QtWidgets.QLabel("Setting:"), 0, 0) + group_layout.addWidget(self.my_widget, 0, 1) + group.setLayout(group_layout) + + layout.addWidget(group) + layout.addStretch() + + self.setLayout(layout) + + def get_current_state(self): + """Return current state for change tracking.""" + return { + 'my_setting': self.my_widget.text(), + } + + def save_settings(self): + """Save chapter-specific settings.""" + ab_settings.my_setting = self.my_widget.text() + logger.info("Saved my settings") + + def reset(self): + """Reset chapter to initial values.""" + self.my_widget.setText(ab_settings.my_setting) + + def restore_defaults(self): + """Restore default values.""" + self.my_widget.setText("default value") +``` + +### Step 2: Import in settings_page.py + +In `settings_page.py`, add your import: + +```python +from .my_chapter import MySettingsChapter +``` + +### Step 3: Add to chapters list + +In the `SettingsPage.__init__()` method, add your chapter: + +```python +# Create chapters +self.startup_chapter = StartupSettingsChapter(self) +self.appearance_chapter = AppearanceSettingsChapter(self) +self.my_chapter = MySettingsChapter(self) # <-- Add this + +# Add chapters to the stack +self.chapters = [ + ("Startup", self.startup_chapter), + ("Appearance", self.appearance_chapter), + ("My Chapter", self.my_chapter), # <-- And this +] +``` + +That's it! Your new chapter is now integrated. + +## BaseSettingsChapter Interface + +All chapters must inherit from `BaseSettingsChapter` and implement these methods: + +- **`get_current_state()`** - Return the current state as a dictionary for change tracking +- **`save_settings()`** - Save the chapter's settings to `ab_settings` +- **`reset()`** - Reset widgets to current `ab_settings` values +- **`restore_defaults()`** - Set widgets to default values + +### Change Tracking + +The base class automatically tracks changes using the `changed` signal: + +1. Override `get_current_state()` to return a dictionary of current values +2. Connect widget signals to `self.changed.emit()` to notify of changes +3. The save button will be enabled/disabled automatically based on changes + +Example: +```python +def __init__(self, parent=None): + super().__init__(parent) + self.my_widget = QtWidgets.QLineEdit() + self.build_layout() + self.connect_signals() + +def connect_signals(self): + # Emit changed signal when the widget changes + self.my_widget.textChanged.connect(self.changed.emit) + +def get_current_state(self): + """Return current state for change tracking.""" + return { + 'my_value': self.my_widget.text(), + } +``` + +## Existing Chapters + +### StartupSettingsChapter (`startup.py`) +Manages: +- Brightway directory selection and management +- Startup project selection +- Directory validation and project discovery + +### AppearanceSettingsChapter (`appearance.py`) +Manages: +- Theme selection (Light/Dark) +- Future: Font sizes, colors, etc. + +## Testing + +Test the settings page with: + +```bash +python test_settings_page.py +``` + +## Best Practices + +1. **Keep chapters focused** - Each chapter should handle a specific area of settings +2. **Use QGroupBox** - Organize widgets within chapters using group boxes +3. **Add tooltips** - Help users understand what each setting does +4. **Validate input** - Check settings before saving +5. **Log changes** - Use logger to record setting changes +6. **Handle errors gracefully** - Show appropriate error messages to users diff --git a/activity_browser/app/pages/settings/__init__.py b/activity_browser/app/pages/settings/__init__.py new file mode 100644 index 000000000..50844ed9f --- /dev/null +++ b/activity_browser/app/pages/settings/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +from .settings_page import SettingsPage +from .base import BaseSettingsChapter + +__all__ = ["SettingsPage", "BaseSettingsChapter"] diff --git a/activity_browser/app/pages/settings/appearance.py b/activity_browser/app/pages/settings/appearance.py new file mode 100644 index 000000000..dfa16d74a --- /dev/null +++ b/activity_browser/app/pages/settings/appearance.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +from loguru import logger +from qtpy import QtWidgets + +from activity_browser.app import settings +from activity_browser.app.pages.settings.base import BaseSettingsChapter + + +class AppearanceSettingsChapter(BaseSettingsChapter): + """Chapter for appearance-related settings.""" + + theme_map = { + "default": "System default", + "light": "Light theme", + "dark": "Dark theme compatibility", + } + + def __init__(self, parent=None): + super().__init__(parent) + + # Theme selector + self.theme_combo = QtWidgets.QComboBox() + self.theme_combo.addItems(self.theme_map.values()) + self.theme_combo.setCurrentText(self.theme_map.get(settings["appearance"]["theme"], "System default")) + + self.build_layout() + self.connect_signals() + + def connect_signals(self): + """Connect signals and slots.""" + # Emit changed signal when settings change + self.theme_combo.currentTextChanged.connect(lambda: self.changed.emit()) + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Theme section + theme_group = QtWidgets.QGroupBox("Theme") + theme_layout = QtWidgets.QGridLayout() + theme_layout.addWidget(QtWidgets.QLabel("Theme:"), 0, 0) + theme_layout.addWidget(self.theme_combo, 0, 1) + theme_layout.addWidget(QtWidgets.QLabel("(Requires restart)"), 0, 2) + theme_group.setLayout(theme_layout) + + layout.addWidget(theme_group) + layout.addStretch() + + self.setLayout(layout) + + def get_current_state(self): + """Get the current state for change tracking.""" + return { + 'theme': self.theme_combo.currentText(), + } + + def save_settings(self): + """Save appearance settings.""" + new_theme = self.theme_combo.currentText() + settings["appearance"]["theme"] = [key for key, value in self.theme_map.items() if value == new_theme][0] + settings.save() + logger.info(f"Saved theme as: {new_theme}") + + def reset(self): + """Reset to initial values.""" + self.theme_combo.setCurrentText(self.theme_map.get(settings["appearance"]["theme"], "System default")) + + def restore_defaults(self): + """Restore default values.""" + self.theme_combo.setCurrentText("Light theme") diff --git a/activity_browser/app/pages/settings/base.py b/activity_browser/app/pages/settings/base.py new file mode 100644 index 000000000..9cd455f02 --- /dev/null +++ b/activity_browser/app/pages/settings/base.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from qtpy import QtCore, QtWidgets + + +class BaseSettingsChapter(QtWidgets.QWidget): + """Base class for settings chapters.""" + + # Signal emitted when settings change + changed = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.settings_page = parent + self._initial_state = None + + def get_current_state(self): + """ + Override this to return the current state of the chapter. + Should return a dictionary or tuple representing current values. + """ + return {} + + def has_changes(self): + """Check if the chapter has unsaved changes.""" + if self._initial_state is None: + return False + return self.get_current_state() != self._initial_state + + def save_settings(self): + """Override this to save chapter-specific settings.""" + pass + + def reset(self): + """Override this to reset chapter to initial values.""" + pass + + def restore_defaults(self): + """Override this to restore default values.""" + pass diff --git a/activity_browser/app/pages/settings/settings_page.py b/activity_browser/app/pages/settings/settings_page.py new file mode 100644 index 000000000..64c67f082 --- /dev/null +++ b/activity_browser/app/pages/settings/settings_page.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +from loguru import logger +from pathlib import Path + +from qtpy import QtWidgets + +from bw2data import projects + +from activity_browser.settings import ab_settings +from .startup import StartupSettingsChapter +from .appearance import AppearanceSettingsChapter + + +class SettingsPage(QtWidgets.QWidget): + """Settings page with a sidebar navigation for different settings chapters.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("SettingsPage") + + # Store initial state for cancel functionality + self.last_project = projects.current + self.last_bwdir = projects._base_data_dir + + # Chapter list (sidebar) + self.chapter_list = QtWidgets.QListWidget() + self.chapter_list.setMaximumWidth(200) + self.chapter_list.setMinimumWidth(100) + self.chapter_list.setSpacing(2) + + # Stacked widget for chapter content + self.content_stack = QtWidgets.QStackedWidget() + + # Create chapters + self.startup_chapter = StartupSettingsChapter(self) + self.appearance_chapter = AppearanceSettingsChapter(self) + + # Add chapters to the stack + self.chapters = [ + ("Startup", self.startup_chapter), + ("Appearance", self.appearance_chapter), + ] + + for name, widget in self.chapters: + self.chapter_list.addItem(name) + self.content_stack.addWidget(widget) + + # Select first chapter by default + self.chapter_list.setCurrentRow(0) + + # Buttons + self.button_layout = QtWidgets.QHBoxLayout() + self.save_button = QtWidgets.QPushButton("Save") + self.cancel_button = QtWidgets.QPushButton("Cancel") + self.restore_defaults_button = QtWidgets.QPushButton("Restore Defaults") + + self.button_layout.addWidget(self.restore_defaults_button) + self.button_layout.addStretch() + self.button_layout.addWidget(self.cancel_button) + self.button_layout.addWidget(self.save_button) + + # Build layout + self.build_layout() + self.connect_signals() + + # Store initial state and disable save button initially + self.store_initial_state() + self.save_button.setEnabled(False) + + def build_layout(self): + """Build the main layout with sidebar and content area.""" + # Main content area with sidebar and content + content_layout = QtWidgets.QHBoxLayout() + content_layout.addWidget(self.chapter_list) + + # Add vertical separator + separator = QtWidgets.QFrame() + separator.setFrameShape(QtWidgets.QFrame.VLine) + separator.setFrameShadow(QtWidgets.QFrame.Sunken) + content_layout.addWidget(separator) + + content_layout.addWidget(self.content_stack, 1) + + # Main layout + main_layout = QtWidgets.QVBoxLayout() + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.addLayout(content_layout, 1) + main_layout.addLayout(self.button_layout) + + self.setLayout(main_layout) + + # Set minimum size for resizability + self.setMinimumSize(400, 300) + + def connect_signals(self): + """Connect signals and slots.""" + self.chapter_list.currentRowChanged.connect(self.content_stack.setCurrentIndex) + self.save_button.clicked.connect(self.save_settings) + self.cancel_button.clicked.connect(self.cancel_settings) + self.restore_defaults_button.clicked.connect(self.restore_defaults) + + # Connect change signals from each chapter + for name, chapter in self.chapters: + if hasattr(chapter, 'changed'): + chapter.changed.connect(self.on_chapter_changed) + + def store_initial_state(self): + """Store the initial state of all chapters.""" + for name, chapter in self.chapters: + if hasattr(chapter, 'get_current_state'): + chapter._initial_state = chapter.get_current_state() + + def on_chapter_changed(self): + """Called when any chapter's settings change.""" + has_changes = self.has_changes() + self.save_button.setEnabled(has_changes) + + def has_changes(self): + """Check if any chapter has unsaved changes.""" + for name, chapter in self.chapters: + if hasattr(chapter, 'has_changes') and chapter.has_changes(): + return True + return False + + def save_settings(self): + """Save all settings from all chapters.""" + for name, chapter in self.chapters: + if hasattr(chapter, 'save_settings'): + chapter.save_settings() + + ab_settings.write_settings() + logger.info("Settings saved successfully") + + # Store new initial state and disable save button + self.store_initial_state() + self.save_button.setEnabled(False) + + def cancel_settings(self): + """Cancel changes and revert to previous state.""" + logger.info("Cancelling settings changes") + if projects._base_data_dir != self.last_bwdir: + projects.change_base_directories(Path(self.last_bwdir), update=False) + projects.set_current(self.last_project, update=False) + + # Reset all chapters + for name, chapter in self.chapters: + if hasattr(chapter, 'reset'): + chapter.reset() + + # Disable save button after reset + self.save_button.setEnabled(False) + + def restore_defaults(self): + """Restore default settings for the current chapter.""" + current_index = self.chapter_list.currentRow() + if current_index >= 0: + name, chapter = self.chapters[current_index] + if hasattr(chapter, 'restore_defaults'): + chapter.restore_defaults() + logger.info(f"Restored defaults for {name}") + # Check for changes after restoring defaults + self.on_chapter_changed() diff --git a/activity_browser/app/pages/settings/startup.py b/activity_browser/app/pages/settings/startup.py new file mode 100644 index 000000000..c42c555a4 --- /dev/null +++ b/activity_browser/app/pages/settings/startup.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +import os +from loguru import logger +from pathlib import Path + +from peewee import SqliteDatabase, OperationalError +from qtpy import QtCore, QtWidgets + +from bw2data import projects + +from activity_browser.app import settings +from .base import BaseSettingsChapter + + +class StartupSettingsChapter(BaseSettingsChapter): + """Chapter for startup-related settings.""" + + def __init__(self, parent=None): + super().__init__(parent) + + # Brightway directory + self.bwdir_variables = set() + self.bwdir_combo = QtWidgets.QComboBox() + self.bwdir_browse_button = QtWidgets.QPushButton("Browse") + self.bwdir_remove_button = QtWidgets.QPushButton("Remove") + self.update_bwdir_combo() + + # Startup project + self.startup_project_combo = QtWidgets.QComboBox() + self.update_project_combo() + + self.build_layout() + self.connect_signals() + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Brightway directory section + bwdir_group = QtWidgets.QGroupBox("Brightway Directory") + bwdir_layout = QtWidgets.QGridLayout() + bwdir_layout.addWidget(QtWidgets.QLabel("Directory:"), 0, 0) + bwdir_layout.addWidget(self.bwdir_combo, 0, 1) + bwdir_layout.addWidget(self.bwdir_browse_button, 0, 2) + bwdir_layout.addWidget(self.bwdir_remove_button, 0, 3) + bwdir_group.setLayout(bwdir_layout) + + # Startup project section + project_group = QtWidgets.QGroupBox("Startup Project") + project_layout = QtWidgets.QGridLayout() + project_layout.addWidget(QtWidgets.QLabel("Project:"), 0, 0) + project_layout.addWidget(self.startup_project_combo, 0, 1) + project_group.setLayout(project_layout) + + layout.addWidget(bwdir_group) + layout.addWidget(project_group) + layout.addStretch() + + self.setLayout(layout) + + def connect_signals(self): + """Connect signals and slots.""" + self.bwdir_browse_button.clicked.connect(self.browse_bwdir) + self.bwdir_remove_button.clicked.connect(self.remove_bwdir) + + # Emit changed signal when settings change + self.bwdir_combo.currentTextChanged.connect(lambda: self.changed.emit()) + self.startup_project_combo.currentTextChanged.connect(lambda: self.changed.emit()) + + def browse_bwdir(self): + """Browse for a brightway directory.""" + path = QtWidgets.QFileDialog.getExistingDirectory( + self, "Select a brightway2 database folder" + ) + if not path: + return + + if os.path.isfile(os.path.join(path, "projects.db")): + self.bwdir_combo.addItem(path) + return + + reply = QtWidgets.QMessageBox.question( + self, + "New brightway data directory?", + 'This directory does not contain any projects. Switching to this directory will create a new brightway2 data folder here.', + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel, + ) + + if reply == QtWidgets.QMessageBox.Cancel: + return + + self.bwdir_combo.addItem(path) + + + def remove_bwdir(self): + """Remove the selected brightway directory from the list.""" + reply = QtWidgets.QMessageBox.question( + self, + "Delete Brightway2 directory?", + "This action will remove the local information only, click 'Yes' to remove\n" + "the projects. Data on the 'disk' will remain untouched and needs to be removed manually", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel, + ) + if reply == QtWidgets.QMessageBox.Cancel: + return + + removed_dir = self.bwdir_combo.currentText() + removed_index = self.bwdir_combo.currentIndex() + self.bwdir_combo.setCurrentText(settings["startup"]["brightway_directory"]) + self.bwdir_combo.removeItem(removed_index) + settings["startup"]["saved_brightway_directories"].remove(removed_dir) + + def update_project_combo(self, path: str = None): + """Update the project combo box.""" + self.startup_project_combo.clear() + if path: + project_names = self.get_projects_from_path(path) + else: + project_names = self.get_projects_from_path(settings["startup"]["brightway_directory"]) + + if project_names: + self.startup_project_combo.addItems(project_names) + else: + logger.warning("No projects found in this directory.") + + if settings["startup"]["startup_project"] in project_names: + self.startup_project_combo.setCurrentText(settings["startup"]["startup_project"]) + else: + settings["startup"]["startup_project"] = "" + self.startup_project_combo.setCurrentIndex(-1) + + def get_projects_from_path(self, path: str): + """Get project names from a brightway directory.""" + database_file = os.path.join(path, "projects.db") + if not os.path.exists(database_file): + return [] + db = SqliteDatabase(database_file) + + try: + cursor = db.execute_sql('SELECT "name" FROM "projectdataset"') + except OperationalError as e: + if "no such table" in str(e): + return [] + raise + return [i[0] for i in cursor.fetchall()] + + + def update_bwdir_combo(self): + """Update the brightway directory combo box.""" + current_dir = settings["startup"]["brightway_directory"] + available_dirs = settings["startup"].get("saved_brightway_directories", []) + + self.bwdir_combo.clear() + self.bwdir_combo.addItems(available_dirs) + self.bwdir_combo.setCurrentText(current_dir) + + def get_current_state(self): + """Get the current state for change tracking.""" + return { + 'bwdir': self.bwdir_combo.currentText(), + 'startup_project': self.startup_project_combo.currentText(), + } + + def save_settings(self): + """Save startup settings.""" + # Save brightway directory + current_bw_dir = settings["startup"]["brightway_directory"] + new_bw_dir = self.bwdir_combo.currentText() + if new_bw_dir and new_bw_dir != current_bw_dir: + settings["startup"]["brightway_directory"] = new_bw_dir + logger.info(f"Saved startup brightway directory as: {new_bw_dir}") + projects.change_base_directories(Path(new_bw_dir), update=False) + + # Save startup project + current_startup_project = settings["startup"]["startup_project"] + new_startup_project = self.startup_project_combo.currentText() + if new_startup_project and new_startup_project != current_startup_project: + settings["startup"]["startup_project"] = new_startup_project + logger.info(f"Saved startup project as: {new_startup_project}") + + settings.save() + + def reset(self): + """Reset to initial values.""" + self.update_bwdir_combo(settings["startup"]["brightway_directory"]) + self.update_project_combo() + + def restore_defaults(self): + """Restore default values.""" + default_dir = settings["startup"]["brightway_directory"] + self.change_bwdir(default_dir) + self.startup_project_combo.setCurrentText( + "default" if "default" in self.get_projects_from_path(default_dir) else "" + ) diff --git a/activity_browser/app/panes/project_manager.py b/activity_browser/app/panes/project_manager.py index 4afeaf734..962b9e316 100644 --- a/activity_browser/app/panes/project_manager.py +++ b/activity_browser/app/panes/project_manager.py @@ -6,7 +6,8 @@ import bw2data as bd from bw2io import remote -from activity_browser import app, ui, app, utils +from activity_browser import app, ui +from activity_browser.bwutils.commontasks import get_templates from activity_browser.settings import ab_settings from activity_browser.ui import widgets @@ -73,7 +74,7 @@ def build_project_df(self) -> pd.DataFrame: def build_template_df(self) -> pd.DataFrame: data = {} - templates = utils.get_templates() + templates = get_templates() remote_templates = remote.get_projects() for name in sorted(templates): diff --git a/activity_browser/bwutils/commontasks.py b/activity_browser/bwutils/commontasks.py index 23c034b66..0252e16c7 100644 --- a/activity_browser/bwutils/commontasks.py +++ b/activity_browser/bwutils/commontasks.py @@ -1,3 +1,4 @@ +import os import hashlib import textwrap from datetime import datetime @@ -16,8 +17,6 @@ from .utils import Parameter - - """ bwutils is a collection of methods that build upon brightway2 and are generic enough to provide here so that we avoid re-typing the same code in different parts of the Activity Browser. @@ -496,3 +495,36 @@ def get_LCIA_method_name_dict(keys: list) -> dict: values: brightway2 method tuples """ return {", ".join(key): key for key in keys} + + +# Common tasks +def savefilepath( + default_file_name: str = "AB_file", file_filter: str = "All Files (*.*)" +): + """A central function to get a safe file path.""" + from qtpy import QtWidgets + + safe_name = bd.utils.safe_filename(default_file_name, add_hash=False) + filepath, _ = QtWidgets.QFileDialog.getSaveFileName( + parent=None, + caption="Choose location for saving", + dir=os.path.join(os.path.expanduser("~"), safe_name), + filter=file_filter, + ) + return filepath + + +def get_templates() -> dict: + import platformdirs, os + + base_dir = platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="ActivityBrowser") + template_dir = os.path.join(base_dir, "templates") + os.makedirs(template_dir, exist_ok=True) + + collection = {} + + for file in os.listdir(template_dir): + if file.endswith(".tar.gz"): + collection[file[:-7]] = os.path.join(template_dir, file) + + return collection \ No newline at end of file diff --git a/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py b/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py index a59b3d93e..949d43fa1 100644 --- a/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py +++ b/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py @@ -9,9 +9,20 @@ from activity_browser.mod import bw2data as bd from ...info import __ei_versions__ -from ...utils import sort_semantic_versions +def sort_semantic_versions(versions, highest_to_lowest: bool = True) -> list: + """Return a sorted (default highest to lowest) list of semantic versions. + + Sorts based on the semantic versioning system. + """ + return list( + sorted( + versions, + key=lambda x: tuple(map(int, x.split("."))), + reverse=highest_to_lowest, + ) + ) def create_default_biosphere3(version) -> None: @@ -19,7 +30,7 @@ def create_default_biosphere3(version) -> None: # format version number to only Major/Minor version = version[:3] - if version == sort_semantic_versions(__ei_versions__)[0][:3]: + if version == __ei_versions__[0][:3]: logger.debug(f"Installing biosphere version >{version}<") # most recent version eb = Ecospold2BiosphereImporter() @@ -56,7 +67,7 @@ def extract_flow_data(o): lci_dirpath = os.path.join(os.path.dirname(__file__), "legacy_biosphere") # find the most recent legacy biosphere that is equal to or older than chosen version - for ei_version in sort_semantic_versions(__ei_versions__): + for ei_version in __ei_versions__: use_version = ei_version fp = os.path.join( lci_dirpath, f"ecoinvent elementary flows {use_version}.xml.zip" diff --git a/activity_browser/bwutils/filesystem.py b/activity_browser/bwutils/filesystem.py new file mode 100644 index 000000000..bbbfe2d3c --- /dev/null +++ b/activity_browser/bwutils/filesystem.py @@ -0,0 +1,25 @@ +import platformdirs +from pathlib import Path + +import bw2data as bd + + +def get_package_path() -> Path: + path = Path(__file__).resolve().parents[2] + path.mkdir(parents=True, exist_ok=True) + return + +def get_appdata_path() -> Path: + path = Path(platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="pylca")) + path.mkdir(parents=True, exist_ok=True) + return path + +def get_project_path() -> Path: + path = bd.projects._base_data_dir + path.mkdir(parents=True, exist_ok=True) + return path + +def get_project_ab_path() -> Path: + path = Path(bd.projects._base_data_dir) / "activity_browser" + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/activity_browser/bwutils/settings.py b/activity_browser/bwutils/settings.py new file mode 100644 index 000000000..e4948de32 --- /dev/null +++ b/activity_browser/bwutils/settings.py @@ -0,0 +1,77 @@ +import os +import sys +import json +import bw2data as bd +import bw2data.signals as bw_signals + +from activity_browser.bwutils.filesystem import get_project_ab_path, get_appdata_path + +defaults = { + "startup": { + "brightway_directory": str(bd.projects._base_data_dir), + "saved_brightway_directories": [str(bd.projects._base_data_dir)], + "startup_project": "default", + }, + "appearance": { + "theme": "default", + } +} + + +class Settings: + def __init__(self): + self.global_config = {} + self.virtual_config = {} + self.project_config = {} + + self.load_global_settings() + self.load_virtual_settings() + self.load_project_settings() + + bw_signals.project_changed.connect(self.load_project_settings) + + def __getitem__(self, key): + if key in self.virtual_config: + return self.virtual_config[key] + if key in self.project_config: + return self.project_config[key] + return self.global_config[key] + + def __setitem__(self, key, value): + if isinstance(key, tuple): + key, subkey = key + else: + subkey = "global" + + if subkey == "global": + self.global_config[key] = value + elif subkey == "project": + self.project_config[key] = value + else: + raise KeyError("Subkey must be 'global' or 'project'") + + def save(self): + global_path = get_appdata_path() / "settings.json" + json.dump(self.global_config, open(global_path, "w"), indent=4) + + project_path = get_project_ab_path() / "settings.json" + json.dump(self.project_config, open(project_path, "w"), indent=4) + + def load_global_settings(self): + global_path = get_appdata_path() / "settings.json" + self.global_config = json.load(open(global_path)) if global_path.exists() else defaults.copy() + + def load_project_settings(self, *args, **kwargs): + project_path = get_project_ab_path() / "settings.json" + self.project_config = json.load(open(project_path)) if project_path.exists() else {} + + def load_virtual_settings(self): + pass # Implementation later based on environment variables + + def reset_to_defaults(self): + self.global_config.read_dict(defaults) + self.global_config.write(open(get_appdata_path() / "settings.ini", "w")) + + os.remove(get_project_ab_path() / "settings.ini") + self.load_project_settings() + diff --git a/activity_browser/info.py b/activity_browser/info.py index 159d10fc9..98a8b45bc 100644 --- a/activity_browser/info.py +++ b/activity_browser/info.py @@ -13,55 +13,5 @@ except PackageNotFoundError: __version__ = "0.0.0" - -def get_compatible_versions() -> list: - """Get compatible versions of ecoinvent for this AB version. - - Reads this file on github repo: activity-browser/better_biosphere_handling/compatible_ei_versions.txt'. - Converts file content to available ecoinvent versions for each version of AB. - Finds the correct available versions for this AB version, if failing to read version, - the lowest version in the file is chosen. - """ - try: - # read versions - versions_URL = "https://raw.githubusercontent.com/LCA-ActivityBrowser/activity-browser/main/activity_browser/bwutils/ecoinvent_biosphere_versions/compatible_ei_versions.txt" - page, error = safe_link_fetch(versions_URL) - if not error: - file = page.text - else: - # silently try a local fallback: - logger.debug( - f"Reading online compatible ecoinvent versions failed " - f"-attempting local fallback- with this error: {error}" - ) - file_path = os.path.join( - os.path.dirname(__file__), - "bwutils", - "ecoinvent_biosphere_versions", - "compatible_ei_versions.txt", - ) - with open(file_path, "r") as f: - file = f.read() - all_versions = ast.literal_eval(file) - - # select either the latest lower version available or if none available the lowest version for safety - sorted_versions = sort_semantic_versions(all_versions.keys()) - for ab_version in sorted_versions: - if sort_semantic_versions([__version__, ab_version])[0] == __version__: - # current version is higher than or equal to tested AB version: - ei_versions = all_versions[ab_version] - break - else: - ei_versions = all_versions[sorted_versions[-1]] - - logger.debug( - f"Following versions of ecoinvent are compatible with AB {__version__}: {ei_versions}" - ) - return ei_versions - - except Exception as error: - logger.debug(f"Reading local fallback failed with: {error}") - return ["3.4", "3.5", "3.6", "3.7", "3.7.1", "3.8", "3.9", "3.9.1"] - - -__ei_versions__ = get_compatible_versions() +# supported EI versions +__ei_versions__ = ["3.4", "3.5", "3.6", "3.7", "3.7.1", "3.8", "3.9", "3.9.1"] diff --git a/activity_browser/mod/bw2io/__init__.py b/activity_browser/mod/bw2io/__init__.py index f1f95b369..8f269f5f2 100644 --- a/activity_browser/mod/bw2io/__init__.py +++ b/activity_browser/mod/bw2io/__init__.py @@ -11,17 +11,19 @@ def ab_bw2setup(version): + + raise Exception("This function is deprecated.") + import bw2io as bi from activity_browser.mod.bw2io.importers.ecospold2_biosphere import ABEcospold2BiosphereImporter from activity_browser.info import __ei_versions__ - from activity_browser.utils import sort_semantic_versions from .migrations import ab_create_core_migrations ab_create_core_migrations() version = version[:3] - if version == sort_semantic_versions(__ei_versions__)[0][:3]: + if version == __ei_versions__[0][:3]: logger.info(f"Installing biosphere version >{version}<") # most recent version bio_import = ABEcospold2BiosphereImporter() diff --git a/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py b/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py index dfae9f34e..b9f644479 100644 --- a/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py +++ b/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py @@ -6,7 +6,20 @@ import os from activity_browser.info import __ei_versions__ -from activity_browser.utils import sort_semantic_versions + + +def sort_semantic_versions(versions, highest_to_lowest: bool = True) -> list: + """Return a sorted (default highest to lowest) list of semantic versions. + + Sorts based on the semantic versioning system. + """ + return list( + sorted( + versions, + key=lambda x: tuple(map(int, x.split("."))), + reverse=highest_to_lowest, + ) + ) class ABEcospold2BiosphereImporter(Ecospold2BiosphereImporter): @@ -58,7 +71,7 @@ def extract_flow_data(o): lci_dirpath = os.path.join(os.path.dirname(mod.__file__), "ecoinvent_biosphere_versions", "legacy_biosphere") # find the most recent legacy biosphere that is equal to or older than chosen version - for ei_version in sort_semantic_versions(__ei_versions__): + for ei_version in __ei_versions__: use_version = ei_version zip_fp = os.path.join( lci_dirpath, f"ecoinvent elementary flows {use_version}.xml.zip" diff --git a/activity_browser/ui/web/base.py b/activity_browser/ui/web/base.py index 14b38e4cc..63c8c26f5 100644 --- a/activity_browser/ui/web/base.py +++ b/activity_browser/ui/web/base.py @@ -95,7 +95,10 @@ def send_json(self) -> None: return self.bridge.graph_ready.emit(self.graph.json_data) css_path = webutils.get_static_css_path(self.css_file) - css_code = utils.read_file_text(css_path) + + with open(css_path, "r") as css_file: + css_code = css_file.read() + style_element = "" self.bridge.style.emit(style_element) diff --git a/activity_browser/ui/web/tree_navigator.py b/activity_browser/ui/web/tree_navigator.py index 5d657bef7..50bca12f9 100644 --- a/activity_browser/ui/web/tree_navigator.py +++ b/activity_browser/ui/web/tree_navigator.py @@ -19,14 +19,14 @@ Edge as GraphEdge, GroupedNodes as GraphGroupedNodes, ) +from bw2data.backends import ActivityDataset from activity_browser import app -from bw2data.backends import ActivityDataset -from activity_browser.utils import get_base_path -from .base import BaseGraph, BaseNavigatorWidget -from ..widgets.combobox import CheckableComboBox -from ...bwutils.commontasks import identify_activity_type +from activity_browser.bwutils.filesystem import get_package_path +from activity_browser.bwutils.commontasks import identify_activity_type +from activity_browser.ui.widgets import CheckableComboBox +from .base import BaseGraph, BaseNavigatorWidget class SmallComboBox(QtWidgets.QComboBox): @@ -47,7 +47,7 @@ class TreeNavigatorWidget(BaseNavigatorWidget): Green flows: Avoided impacts """ - HTML_FILE = str(get_base_path().joinpath("static", "tree_navigator.html").resolve()) + HTML_FILE = str(get_package_path() / "static" / "tree_navigator.html") def __init__(self, cs_name, parent=None): super().__init__(parent, css_file="tree_navigator.css") diff --git a/activity_browser/ui/web/webutils.py b/activity_browser/ui/web/webutils.py index 7da643e05..f300ffabd 100644 --- a/activity_browser/ui/web/webutils.py +++ b/activity_browser/ui/web/webutils.py @@ -2,14 +2,14 @@ import os # type "localhost:3999" in Chrome for DevTools of AB web content -from activity_browser.utils import get_base_path +from activity_browser.bwutils.filesystem import get_package_path os.environ["QTWEBENGINE_REMOTE_DEBUGGING"] = "3999" def get_static_js_path(file_name: str = "") -> str: - return str(get_base_path().joinpath("static", "javascript", file_name)) + return str(get_package_path() / "static" / "javascript" / file_name) def get_static_css_path(file_name: str = "") -> str: - return str(get_base_path().joinpath("static", "css", file_name)) + return str(get_package_path() / "static" / "css" / file_name) \ No newline at end of file diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index 67a2dc17e..a19b256d2 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -10,7 +10,7 @@ from .line import ABHLine, ABVLine from .formula_edit import ABFormulaEdit -from .combobox import ABComboBox +from .combobox import ABComboBox, CheckableComboBox from .button_collapser import ABRadioButtonCollapser from .wizard import ABWizard from .wizard_page import ABWizardPage, ABThreadedWizardPage diff --git a/activity_browser/ui/widgets/plot.py b/activity_browser/ui/widgets/plot.py index 998c342b4..b8a1b84cf 100644 --- a/activity_browser/ui/widgets/plot.py +++ b/activity_browser/ui/widgets/plot.py @@ -40,7 +40,7 @@ def get_canvas_size_in_inches(self): def to_png(self): """Export to .png format.""" - from activity_browser.utils import savefilepath + from activity_browser.bwutils.commontasks import savefilepath filepath = savefilepath( default_file_name=self.plot_name, file_filter=self.PNG_FILTER @@ -52,7 +52,7 @@ def to_png(self): def to_svg(self): """Export to .svg format.""" - from activity_browser.utils import savefilepath + from activity_browser.bwutils.commontasks import savefilepath filepath = savefilepath( default_file_name=self.plot_name, file_filter=self.SVG_FILTER diff --git a/activity_browser/utils.py b/activity_browser/utils.py deleted file mode 100644 index 655d8b1f9..000000000 --- a/activity_browser/utils.py +++ /dev/null @@ -1,92 +0,0 @@ -import os -from pathlib import Path -from typing import Iterable, Tuple - -import requests -from qtpy import QtWidgets - -from activity_browser.mod import bw2data as bd - -from .settings import ab_settings - - -def get_base_path() -> Path: - return Path(__file__).resolve().parents[0] - - -def read_file_text(file_dir: str) -> str: - if not file_dir: - raise ValueError("File path passed is empty") - file = open(file_dir, mode="r", encoding="UTF-8") - if not file: - raise ValueError("File does not exist in the passed path:", file_dir) - text = file.read() - file.close() - return text - - -def savefilepath( - default_file_name: str = "AB_file", file_filter: str = "All Files (*.*)" -): - """A central function to get a safe file path.""" - safe_name = bd.utils.safe_filename(default_file_name, add_hash=False) - filepath, _ = QtWidgets.QFileDialog.getSaveFileName( - parent=None, - caption="Choose location for saving", - dir=os.path.join(ab_settings.data_dir, safe_name), - filter=file_filter, - ) - return filepath - - -def safe_link_fetch(url: str) -> Tuple[object, object]: - """ - Get a web-page or file from the internet or the error of getting the link. - - Parameters - ---------- - url: a link - - Returns - ------- - object: error if any, otherwise None - object: response if no error, otherwise None - """ - try: - response = requests.get(url, timeout=2) # retrieve the page from the URL - response.raise_for_status() - except Exception as error: - return (None, error) - - return (response, None) - - -def sort_semantic_versions(versions: Iterable, highest_to_lowest: bool = True) -> list: - """Return a sorted (default highest to lowest) list of semantic versions. - - Sorts based on the semantic versioning system. - """ - return list( - sorted( - versions, - key=lambda x: tuple(map(int, x.split("."))), - reverse=highest_to_lowest, - ) - ) - - -def get_templates() -> dict: - import platformdirs, os - - base_dir = platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="ActivityBrowser") - template_dir = os.path.join(base_dir, "templates") - os.makedirs(template_dir, exist_ok=True) - - collection = {} - - for file in os.listdir(template_dir): - if file.endswith(".tar.gz"): - collection[file[:-7]] = os.path.join(template_dir, file) - - return collection - From ea7486b0de429420ca31aa44fe7ee5e999d10180 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 12 Nov 2025 09:16:03 +0100 Subject: [PATCH 109/267] Settings stuff 2 --- activity_browser/bwutils/filesystem.py | 2 +- activity_browser/ui/web/base.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/activity_browser/bwutils/filesystem.py b/activity_browser/bwutils/filesystem.py index bbbfe2d3c..30370872a 100644 --- a/activity_browser/bwutils/filesystem.py +++ b/activity_browser/bwutils/filesystem.py @@ -7,7 +7,7 @@ def get_package_path() -> Path: path = Path(__file__).resolve().parents[2] path.mkdir(parents=True, exist_ok=True) - return + return path def get_appdata_path() -> Path: path = Path(platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="pylca")) diff --git a/activity_browser/ui/web/base.py b/activity_browser/ui/web/base.py index 63c8c26f5..fdb363926 100644 --- a/activity_browser/ui/web/base.py +++ b/activity_browser/ui/web/base.py @@ -12,7 +12,6 @@ from activity_browser.settings import ab_settings from activity_browser.mod import bw2data as bd -from ... import utils from ...ui.icons import qicons from . import webutils from .webengine_page import Page From 2af81e36301e122750af5f4770e884bc2c4ad6d8 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 12 Nov 2025 10:27:36 +0100 Subject: [PATCH 110/267] Settings stuff 3 --- .../app/pages/settings/appearance.py | 35 ++--- .../app/pages/settings/settings_page.py | 54 +++----- .../app/pages/settings/startup.py | 122 ++++++------------ activity_browser/app/signalling.py | 15 +++ activity_browser/bwutils/settings.py | 18 ++- 5 files changed, 113 insertions(+), 131 deletions(-) diff --git a/activity_browser/app/pages/settings/appearance.py b/activity_browser/app/pages/settings/appearance.py index dfa16d74a..b7b5bd492 100644 --- a/activity_browser/app/pages/settings/appearance.py +++ b/activity_browser/app/pages/settings/appearance.py @@ -20,11 +20,10 @@ def __init__(self, parent=None): # Theme selector self.theme_combo = QtWidgets.QComboBox() - self.theme_combo.addItems(self.theme_map.values()) - self.theme_combo.setCurrentText(self.theme_map.get(settings["appearance"]["theme"], "System default")) self.build_layout() self.connect_signals() + self.reset() def connect_signals(self): """Connect signals and slots.""" @@ -48,23 +47,25 @@ def build_layout(self): self.setLayout(layout) - def get_current_state(self): - """Get the current state for change tracking.""" - return { + # --- Settings management methods --- # + def reset(self): + """(Re)set to initial values.""" + self.theme_combo.clear() + self.theme_combo.addItems(self.theme_map.values()) + self.theme_combo.setCurrentText(self.theme_map.get(settings["appearance"]["theme"], "System default")) + + def has_changes(self): + """Check if there are unsaved changes.""" + current_state = { 'theme': self.theme_combo.currentText(), } + initial_state = { + 'theme': self.theme_map.get(settings["appearance"]["theme"], "System default"), + } + return current_state != initial_state - def save_settings(self): - """Save appearance settings.""" + def set_settings(self): + """Save startup settings.""" new_theme = self.theme_combo.currentText() settings["appearance"]["theme"] = [key for key, value in self.theme_map.items() if value == new_theme][0] - settings.save() - logger.info(f"Saved theme as: {new_theme}") - - def reset(self): - """Reset to initial values.""" - self.theme_combo.setCurrentText(self.theme_map.get(settings["appearance"]["theme"], "System default")) - - def restore_defaults(self): - """Restore default values.""" - self.theme_combo.setCurrentText("Light theme") + diff --git a/activity_browser/app/pages/settings/settings_page.py b/activity_browser/app/pages/settings/settings_page.py index 64c67f082..2069ed30f 100644 --- a/activity_browser/app/pages/settings/settings_page.py +++ b/activity_browser/app/pages/settings/settings_page.py @@ -6,7 +6,8 @@ from bw2data import projects -from activity_browser.settings import ab_settings +from activity_browser.app import settings, signals + from .startup import StartupSettingsChapter from .appearance import AppearanceSettingsChapter @@ -64,7 +65,6 @@ def __init__(self, parent=None): self.connect_signals() # Store initial state and disable save button initially - self.store_initial_state() self.save_button.setEnabled(False) def build_layout(self): @@ -94,6 +94,8 @@ def build_layout(self): def connect_signals(self): """Connect signals and slots.""" + signals.project.changed.connect(self.reset_all) + self.chapter_list.currentRowChanged.connect(self.content_stack.setCurrentIndex) self.save_button.clicked.connect(self.save_settings) self.cancel_button.clicked.connect(self.cancel_settings) @@ -103,13 +105,7 @@ def connect_signals(self): for name, chapter in self.chapters: if hasattr(chapter, 'changed'): chapter.changed.connect(self.on_chapter_changed) - - def store_initial_state(self): - """Store the initial state of all chapters.""" - for name, chapter in self.chapters: - if hasattr(chapter, 'get_current_state'): - chapter._initial_state = chapter.get_current_state() - + def on_chapter_changed(self): """Called when any chapter's settings change.""" has_changes = self.has_changes() @@ -125,38 +121,30 @@ def has_changes(self): def save_settings(self): """Save all settings from all chapters.""" for name, chapter in self.chapters: - if hasattr(chapter, 'save_settings'): - chapter.save_settings() + if hasattr(chapter, 'set_settings'): + chapter.set_settings() - ab_settings.write_settings() + settings.save() logger.info("Settings saved successfully") - # Store new initial state and disable save button - self.store_initial_state() - self.save_button.setEnabled(False) + # Reset all chapters to the new saved state + self.reset_all() def cancel_settings(self): """Cancel changes and revert to previous state.""" logger.info("Cancelling settings changes") - if projects._base_data_dir != self.last_bwdir: - projects.change_base_directories(Path(self.last_bwdir), update=False) - projects.set_current(self.last_project, update=False) - - # Reset all chapters + self.reset_all() + + def restore_defaults(self): + """Restore default settings for the current chapter.""" + logger.info("Restoring default settings") + settings.restore_defaults() + self.reset_all() + + def reset_all(self): + """Reset all chapters to their initial states.""" for name, chapter in self.chapters: if hasattr(chapter, 'reset'): chapter.reset() - - # Disable save button after reset self.save_button.setEnabled(False) - - def restore_defaults(self): - """Restore default settings for the current chapter.""" - current_index = self.chapter_list.currentRow() - if current_index >= 0: - name, chapter = self.chapters[current_index] - if hasattr(chapter, 'restore_defaults'): - chapter.restore_defaults() - logger.info(f"Restored defaults for {name}") - # Check for changes after restoring defaults - self.on_chapter_changed() + diff --git a/activity_browser/app/pages/settings/startup.py b/activity_browser/app/pages/settings/startup.py index c42c555a4..53fde243e 100644 --- a/activity_browser/app/pages/settings/startup.py +++ b/activity_browser/app/pages/settings/startup.py @@ -19,18 +19,16 @@ def __init__(self, parent=None): super().__init__(parent) # Brightway directory - self.bwdir_variables = set() self.bwdir_combo = QtWidgets.QComboBox() self.bwdir_browse_button = QtWidgets.QPushButton("Browse") self.bwdir_remove_button = QtWidgets.QPushButton("Remove") - self.update_bwdir_combo() # Startup project self.startup_project_combo = QtWidgets.QComboBox() - self.update_project_combo() self.build_layout() self.connect_signals() + self.reset() def build_layout(self): """Build the chapter layout.""" @@ -67,16 +65,50 @@ def connect_signals(self): self.bwdir_combo.currentTextChanged.connect(lambda: self.changed.emit()) self.startup_project_combo.currentTextChanged.connect(lambda: self.changed.emit()) + # --- Settings management methods --- # + def reset(self): + """(Re)set to initial values.""" + self.bwdir_combo.clear() + self.bwdir_combo.addItems(settings["startup"].get("saved_brightway_directories", [])) + self.bwdir_combo.setCurrentText(settings["startup"]["brightway_directory"]) + + self.startup_project_combo.clear() + self.startup_project_combo.addItems(self.get_projects_from_path(settings["startup"]["brightway_directory"])) + self.startup_project_combo.setCurrentText(settings["startup"]["startup_project"]) + + def has_changes(self): + """Check if there are unsaved changes.""" + current_state = { + 'brightway_directory': self.bwdir_combo.currentText(), + 'saved_brightway_directories': [self.bwdir_combo.itemText(i) for i in range(self.bwdir_combo.count())], + 'startup_project': self.startup_project_combo.currentText(), + } + initial_state = { + 'brightway_directory': settings["startup"]["brightway_directory"], + 'saved_brightway_directories': settings["startup"].get("saved_brightway_directories", []), + 'startup_project': settings["startup"]["startup_project"], + } + return current_state != initial_state + + def set_settings(self): + """Save startup settings.""" + + settings["startup"]["brightway_directory"] = self.bwdir_combo.currentText() + settings["startup"]["saved_brightway_directories"] = [self.bwdir_combo.itemText(i) for i in range(self.bwdir_combo.count())] + settings["startup"]["startup_project"] = self.startup_project_combo.currentText() + + # --- Helper methods --- # def browse_bwdir(self): """Browse for a brightway directory.""" - path = QtWidgets.QFileDialog.getExistingDirectory( + path = Path(QtWidgets.QFileDialog.getExistingDirectory( self, "Select a brightway2 database folder" - ) + )) if not path: return - if os.path.isfile(os.path.join(path, "projects.db")): - self.bwdir_combo.addItem(path) + if (path / "projects.db").is_file(): + self.bwdir_combo.addItem(str(path)) + self.bwdir_combo.setCurrentText(str(path)) return reply = QtWidgets.QMessageBox.question( @@ -89,9 +121,9 @@ def browse_bwdir(self): if reply == QtWidgets.QMessageBox.Cancel: return - self.bwdir_combo.addItem(path) + self.bwdir_combo.addItem(str(path)) + self.bwdir_combo.setCurrentText(str(path)) - def remove_bwdir(self): """Remove the selected brightway directory from the list.""" reply = QtWidgets.QMessageBox.question( @@ -104,31 +136,10 @@ def remove_bwdir(self): if reply == QtWidgets.QMessageBox.Cancel: return - removed_dir = self.bwdir_combo.currentText() removed_index = self.bwdir_combo.currentIndex() self.bwdir_combo.setCurrentText(settings["startup"]["brightway_directory"]) self.bwdir_combo.removeItem(removed_index) - settings["startup"]["saved_brightway_directories"].remove(removed_dir) - - def update_project_combo(self, path: str = None): - """Update the project combo box.""" - self.startup_project_combo.clear() - if path: - project_names = self.get_projects_from_path(path) - else: - project_names = self.get_projects_from_path(settings["startup"]["brightway_directory"]) - - if project_names: - self.startup_project_combo.addItems(project_names) - else: - logger.warning("No projects found in this directory.") - - if settings["startup"]["startup_project"] in project_names: - self.startup_project_combo.setCurrentText(settings["startup"]["startup_project"]) - else: - settings["startup"]["startup_project"] = "" - self.startup_project_combo.setCurrentIndex(-1) - + def get_projects_from_path(self, path: str): """Get project names from a brightway directory.""" database_file = os.path.join(path, "projects.db") @@ -143,52 +154,3 @@ def get_projects_from_path(self, path: str): return [] raise return [i[0] for i in cursor.fetchall()] - - - def update_bwdir_combo(self): - """Update the brightway directory combo box.""" - current_dir = settings["startup"]["brightway_directory"] - available_dirs = settings["startup"].get("saved_brightway_directories", []) - - self.bwdir_combo.clear() - self.bwdir_combo.addItems(available_dirs) - self.bwdir_combo.setCurrentText(current_dir) - - def get_current_state(self): - """Get the current state for change tracking.""" - return { - 'bwdir': self.bwdir_combo.currentText(), - 'startup_project': self.startup_project_combo.currentText(), - } - - def save_settings(self): - """Save startup settings.""" - # Save brightway directory - current_bw_dir = settings["startup"]["brightway_directory"] - new_bw_dir = self.bwdir_combo.currentText() - if new_bw_dir and new_bw_dir != current_bw_dir: - settings["startup"]["brightway_directory"] = new_bw_dir - logger.info(f"Saved startup brightway directory as: {new_bw_dir}") - projects.change_base_directories(Path(new_bw_dir), update=False) - - # Save startup project - current_startup_project = settings["startup"]["startup_project"] - new_startup_project = self.startup_project_combo.currentText() - if new_startup_project and new_startup_project != current_startup_project: - settings["startup"]["startup_project"] = new_startup_project - logger.info(f"Saved startup project as: {new_startup_project}") - - settings.save() - - def reset(self): - """Reset to initial values.""" - self.update_bwdir_combo(settings["startup"]["brightway_directory"]) - self.update_project_combo() - - def restore_defaults(self): - """Restore default values.""" - default_dir = settings["startup"]["brightway_directory"] - self.change_bwdir(default_dir) - self.startup_project_combo.setCurrentText( - "default" if "default" in self.get_projects_from_path(default_dir) else "" - ) diff --git a/activity_browser/app/signalling.py b/activity_browser/app/signalling.py index 9bd55aed6..4e8aeadac 100644 --- a/activity_browser/app/signalling.py +++ b/activity_browser/app/signalling.py @@ -76,6 +76,20 @@ def _flush_metadata(self): logger.debug(f"Metadatastore sync signal completed in {time() - t:.2f} seconds") +class SettingSignals(QObject): + changed = Signal() # Settings have changed + + def __init__(self, parent=None): + from activity_browser.bwutils.settings import Settings + + super().__init__(parent) + Settings().changed.connect(self.emit_changed) + + def emit_changed(self, *args, **kwargs): + """Emit the changed signal.""" + self.changed.emit() + logger.debug("Settings changed signal emitted") + class ABSignals(QObject): """Signals used for the Activity Browser should be defined here. @@ -91,6 +105,7 @@ class ABSignals(QObject): meta = MetaSignals() metadata = MetaDataSignals() parameter = ParameterSignals() + settings = SettingSignals() # import_project = Signal() # Import a project # export_project = Signal() # Export the current project diff --git a/activity_browser/bwutils/settings.py b/activity_browser/bwutils/settings.py index e4948de32..d98ed0901 100644 --- a/activity_browser/bwutils/settings.py +++ b/activity_browser/bwutils/settings.py @@ -1,8 +1,8 @@ import os -import sys import json import bw2data as bd import bw2data.signals as bw_signals +import blinker from activity_browser.bwutils.filesystem import get_project_ab_path, get_appdata_path @@ -19,7 +19,19 @@ class Settings: + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + def __init__(self): + if self._initialized: + return + self._initialized = True + self.global_config = {} self.virtual_config = {} self.project_config = {} @@ -28,6 +40,8 @@ def __init__(self): self.load_virtual_settings() self.load_project_settings() + self.changed = blinker.Signal() + bw_signals.project_changed.connect(self.load_project_settings) def __getitem__(self, key): @@ -56,6 +70,8 @@ def save(self): project_path = get_project_ab_path() / "settings.json" json.dump(self.project_config, open(project_path, "w"), indent=4) + + self.changed.send() def load_global_settings(self): global_path = get_appdata_path() / "settings.json" From 8a0abdc4b05e6f9d090086e4535d50064dd23956 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 12 Nov 2025 10:54:57 +0100 Subject: [PATCH 111/267] Settings stuff 4 --- activity_browser/__main__.py | 30 +- activity_browser/app/__init__.py | 1 + activity_browser/app/main_window.py | 21 +- .../app/pages/parameters/parameter_views.py | 3 - activity_browser/ui/delegates/__init__.py | 2 - activity_browser/ui/delegates/formula.py | 263 ------------------ .../ui/dialogs/progress_dialog.py | 3 +- activity_browser/ui/dialogs/uncertainty.py | 5 +- activity_browser/ui/widgets/central.py | 10 +- 9 files changed, 33 insertions(+), 305 deletions(-) delete mode 100644 activity_browser/ui/delegates/formula.py diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index 6b9dc6d06..c120c2759 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -101,13 +101,7 @@ def load_layout(self): application.main_window.setCentralWidget(central_widget) - self.load_settings() - - def load_settings(self): - self.text_label.setText("Loading project") - thread = SettingsThread(self) - thread.finished.connect(self.load_finished) - thread.start() + self.load_finished() def load_finished(self): from activity_browser import app @@ -127,25 +121,6 @@ def run(self): logger.debug("ABLoader: Importing brightway modules") import bw2data, bw2calc, bw2analyzer, bw2io, bw_functional, bw_processing, matrix_utils -class SettingsThread(QtCore.QThread): - def run(self): - import bw2data as bd - from activity_browser import settings, app - - if settings.ab_settings.settings: - from pathlib import Path - - base_dir = Path(settings.ab_settings.current_bw_dir) - project_name = settings.ab_settings.startup_project - bd.projects.change_base_directories(base_dir, project_name=project_name, update=False) - - if not bd.projects.twofive: - logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") - app.actions.ProjectSwitch.set_warning_bar() - - logger.info(f"Brightway2 data directory: {bd.projects._base_data_dir}") - logger.info(f"Brightway2 current project: {bd.projects.current}") - def setup_logging(): """Configure loguru sinks for console and file logging.""" @@ -189,9 +164,6 @@ def run_activity_browser_no_launcher(): application.main_window.setCentralWidget(central_widget) - settings = SettingsThread() - settings.run() - application.main_window.sync() application.main_window.show() diff --git a/activity_browser/app/__init__.py b/activity_browser/app/__init__.py index c97dbeeb3..e192159ff 100644 --- a/activity_browser/app/__init__.py +++ b/activity_browser/app/__init__.py @@ -20,4 +20,5 @@ main_window = MainWindow() application.main_window = main_window +main_window.apply_settings(load=True) # Ensure settings are applied at startup diff --git a/activity_browser/app/main_window.py b/activity_browser/app/main_window.py index fcc64494b..574e70085 100644 --- a/activity_browser/app/main_window.py +++ b/activity_browser/app/main_window.py @@ -1,3 +1,6 @@ +from pathlib import Path +from loguru import logger + from qtpy import QtCore, QtWidgets import bw2data as bd @@ -19,6 +22,7 @@ def __init__(self, parent=None): if self._initialized: return + self._initialized = True super().__init__(parent) @@ -32,8 +36,6 @@ def __init__(self, parent=None): self.connect_signals() - self._initialized = True - def sync(self): """ Synchronizes the main window layout with the current Brightway2 project. @@ -89,9 +91,22 @@ def sync(self): # Update the window title to reflect the current project self.setWindowTitle(f"Activity Browser - {bd.projects.current}") + def apply_settings(self, load=False): + + base_dir = Path(app.settings["startup"]["brightway_directory"]) + + if load or base_dir != bd.projects._base_data_dir: + project_name = app.settings["startup"]["startup_project"] + bd.projects.change_base_directories(base_dir, project_name=project_name, update=False) + + if not bd.projects.twofive: + logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") + app.actions.ProjectSwitch.set_warning_bar() + + def connect_signals(self): - # Keyboard shortcuts app.signals.project.changed.connect(self.sync) + app.signals.settings.changed.connect(self.apply_settings) def clearPanes(self): for pane in self.panes(): diff --git a/activity_browser/app/pages/parameters/parameter_views.py b/activity_browser/app/pages/parameters/parameter_views.py index acdbe0a3e..a94f1b7dc 100644 --- a/activity_browser/app/pages/parameters/parameter_views.py +++ b/activity_browser/app/pages/parameters/parameter_views.py @@ -136,7 +136,6 @@ def __init__(self, parent=None): # Set delegates for specific columns self.setItemDelegateForColumn(1, delegates.FloatDelegate(self)) - self.setItemDelegateForColumn(2, delegates.FormulaDelegate(self)) self.setItemDelegateForColumn(3, delegates.StringDelegate(self)) self.setItemDelegateForColumn(4, delegates.ViewOnlyUncertaintyDelegate(self)) @@ -162,7 +161,6 @@ def __init__(self, parent=None): # Set delegates for specific columns self.setItemDelegateForColumn(1, delegates.FloatDelegate(self)) - self.setItemDelegateForColumn(2, delegates.FormulaDelegate(self)) self.setItemDelegateForColumn(3, delegates.DatabaseDelegate(self)) self.setItemDelegateForColumn(4, delegates.StringDelegate(self)) self.setItemDelegateForColumn(5, delegates.ViewOnlyUncertaintyDelegate(self)) @@ -194,7 +192,6 @@ def __init__(self, parent=None): # Set delegates for specific columns self.setItemDelegateForColumn(1, delegates.FloatDelegate(self)) - self.setItemDelegateForColumn(2, delegates.FormulaDelegate(self)) self.setItemDelegateForColumn(6, delegates.StringDelegate(self)) self.setItemDelegateForColumn(7, delegates.ListDelegate(self)) self.setItemDelegateForColumn(9, delegates.StringDelegate(self)) diff --git a/activity_browser/ui/delegates/__init__.py b/activity_browser/ui/delegates/__init__.py index 6ff8fd045..e0fc331ac 100644 --- a/activity_browser/ui/delegates/__init__.py +++ b/activity_browser/ui/delegates/__init__.py @@ -4,7 +4,6 @@ from .database import DatabaseDelegate from .delete_button import DeleteButtonDelegate from .float import FloatDelegate -from .formula import FormulaDelegate from .json import JSONDelegate from .list import ListDelegate from .string import StringDelegate @@ -24,7 +23,6 @@ "DatabaseDelegate", "DeleteButtonDelegate", "FloatDelegate", - "FormulaDelegate", "JSONDelegate", "ListDelegate", "StringDelegate", diff --git a/activity_browser/ui/delegates/formula.py b/activity_browser/ui/delegates/formula.py deleted file mode 100644 index 9533acf39..000000000 --- a/activity_browser/ui/delegates/formula.py +++ /dev/null @@ -1,263 +0,0 @@ -# -*- coding: utf-8 -*- -from os import devnull - -from asteval import Interpreter -from qtpy import QtCore, QtGui, QtWidgets -from qtpy.QtCore import Signal, Slot - -from activity_browser import app, app - - -class CalculatorButtons(QtWidgets.QWidget): - """A custom layout containing calculator buttons, emits a signal - for each button pressed. - """ - - button_press = Signal(str) - clear = Signal() - - def __init__(self, parent=None): - super().__init__(parent) - - self.explain_text = """ -In addition to the other buttons on this calculator, the parameter formula -can make use of a large number of Python and Numpy functions, with Numpy -overriding Python where the function names are the same. - -For a more complete list see the `math` module in the Python documentation -or `ufuncs` in de Numpy documentation. - -Keep in mind that the result of a formula must be a scalar value! -""" - rows = [ - [ - ("+", "Add", lambda: self.button_press.emit(" + ")), - ("-", "Subtract", lambda: self.button_press.emit(" - ")), - ("*", "Multiply", lambda: self.button_press.emit(" * ")), - ], - [ - ("/", "Divide", lambda: self.button_press.emit(" / ")), - ("x²", "X to the power of 2", lambda: self.button_press.emit(" ** 2 ")), - ("More...", "Additional functions", self.explanation), - ], - ] - # Construct the layout from the list of lists above. - layout = QtWidgets.QHBoxLayout() - layout.addStretch(1) - for row in rows: - bar = QtWidgets.QToolBar() - bar.setOrientation(QtCore.Qt.Vertical) - for btn in row: - w = QtWidgets.QPushButton(btn[0]) - w.setToolTip(btn[1]) - w.pressed.connect(btn[2]) - w.setFixedSize(50, 50) - bar.addWidget(w) - layout.addWidget(bar) - layout.addStretch(1) - self.setLayout(layout) - - @Slot() - def explanation(self): - return QtWidgets.QMessageBox.question( - self, - "More...", - self.explain_text, - QtWidgets.QMessageBox.Ok, - QtWidgets.QMessageBox.Ok, - ) - - -class FormulaDialog(QtWidgets.QDialog): - def __init__(self, parent=None, flags=QtCore.Qt.Window): - super().__init__(parent=parent, f=flags) - self.setWindowTitle("Build a formula") - self.setWindowModality(QtCore.Qt.ApplicationModal) - self.interpreter = None - self.key = ("", "") - - # 6 broad by 6 deep. - grid = QtWidgets.QGridLayout(self) - self.text_field = QtWidgets.QLineEdit(self) - self.text_field.textChanged.connect(self.validate_formula) - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Save | QtWidgets.QDialogButtonBox.Cancel - ) - self.buttons.setSizePolicy( - QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Preferred, - QtWidgets.QSizePolicy.ButtonBox, - ) - ) - self.parameters = QtWidgets.QTableView(self) - model = QtGui.QStandardItemModel(self) - self.parameters.setModel(model) - completer = QtWidgets.QCompleter(model, self) - completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) - self.text_field.setCompleter(completer) - self.parameters.doubleClicked.connect(self.append_parameter_name) - - self.new_parameter_button = app.actions.ParameterNew.get_QButton(self.get_key) - - self.calculator = CalculatorButtons(self) - self.calculator.button_press.connect(self.text_field.insert) - self.calculator.clear.connect(self.text_field.clear) - - grid.addWidget(self.text_field, 0, 0, 5, 1) - grid.addWidget(self.buttons, 5, 0, 1, 1) - grid.addWidget(self.calculator, 0, 1, 5, 1) - grid.addWidget(self.parameters, 0, 2, 5, 1) - grid.addWidget(self.new_parameter_button, 5, 2, 1, 1) - - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - app.signals.added_parameter.connect(self.append_parameter) - self.show() - - def insert_parameters(self, items) -> None: - """Take the given list of parameter names, amounts and types, insert - them into the model. - """ - model = self.parameters.model() - model.clear() - model.setHorizontalHeaderLabels(["Name", "Amount", "Type"]) - for x, item in enumerate(items): - for y, value in enumerate(item): - model_item = QtGui.QStandardItem(str(value)) - model_item.setEditable(False) - model.setItem(x, y, model_item) - self.parameters.resizeColumnsToContents() - - @Slot(str, str, str, name="appendParameter") - def append_parameter(self, name: str, amount: str, p_type: str) -> None: - """Catch new parameters from the wizard and add them to the list.""" - model = self.parameters.model() - x = model.rowCount() - for y, i in enumerate([name, amount, p_type]): - item = QtGui.QStandardItem(i) - item.setEditable(False) - model.setItem(x, y, item) - - # Also include the new parameter in the interpreter. - if self.interpreter: - self.interpreter.symtable.update({name: float(amount)}) - - def insert_interpreter(self, interpreter: Interpreter) -> None: - self.interpreter = interpreter - - def insert_key(self, key: tuple) -> None: - """The key consists of two strings, no more, no less.""" - self.key = key - - def get_key(self) -> tuple: - return self.key - - @property - def formula(self) -> str: - """Look into the text_field and return the formula.""" - return self.text_field.text().strip() - - @formula.setter - def formula(self, value) -> None: - """Take the formula and set it to the text_field widget.""" - if value is None: - self.text_field.clear() - else: - self.text_field.setText(str(value)) - - @Slot(QtCore.QModelIndex) - def append_parameter_name(self, index: QtCore.QModelIndex) -> None: - """Take the index from the parameters table and append the parameter - name to the formula. - """ - param_name = self.parameters.model().index(index.row(), 0).data() - self.text_field.insert(param_name) - - @Slot() - def validate_formula(self) -> None: - """Qt slot triggered whenever a change is detected in the text_field.""" - self.text_field.blockSignals(True) - if self.interpreter: - formula = self.text_field.text().strip() - # Do not write massive amounts of errors to stderr if the user - # is busy writing. - with open(devnull, "w") as errfile: - self.interpreter.err_writer = errfile - self.interpreter(formula) - if len(self.interpreter.error) > 0: - self.buttons.button(QtWidgets.QDialogButtonBox.Save).setEnabled( - False - ) - else: - self.buttons.button(QtWidgets.QDialogButtonBox.Save).setEnabled( - True - ) - self.text_field.blockSignals(False) - - -class FormulaDelegate(QtWidgets.QStyledItemDelegate): - """An extensive delegate to allow users to build and validate formulas - The delegate spawns a dialog containing: - - An editable textfield for the formula. - - A listview containing parameter names that can be used in the formula - - Ok and Cancel buttons, on Ok, validate the formula before saving - For hardmode: also allow the user to create a new parameter from WITHIN - the delegate dialog itself. Requiring us to also include refreshing - for the parameter list. - """ - - ACCEPTED_TABLES = { - "project_parameter", - "database_parameter", - "activity_parameter", - "product", - "technosphere", - "biosphere", - } - - def __init__(self, parent=None): - super().__init__(parent) - - def createEditor(self, parent, option, index): - editor = QtWidgets.QWidget(parent) - dialog = FormulaDialog(editor, QtCore.Qt.Window) - dialog.accepted.connect(lambda: self.commitData.emit(editor)) - # dialog.rejected.connect(signals.parameters_changed.emit) - return editor - - def setEditorData(self, editor: QtWidgets.QWidget, index: QtCore.QModelIndex): - """Populate the editor with data if editing an existing field.""" - dialog = editor.findChild(FormulaDialog) - data = index.data(QtCore.Qt.DisplayRole) - - parent = self.parent() - # Check which table is asking for a list - if getattr(parent, "table_name", "") in self.ACCEPTED_TABLES: - items = parent.get_usable_parameters() - dialog.insert_parameters(items) - dialog.formula = data - interpreter = parent.get_interpreter() - dialog.insert_interpreter(interpreter) - # Now see if we can construct a (partial) key - if hasattr(parent, "key"): - # This works for exchange tables. - dialog.insert_key(parent.key) - elif hasattr(parent, "get_key"): - dialog.insert_key(parent.get_key()) - - def setModelData( - self, - editor: QtWidgets.QWidget, - model: QtCore.QAbstractItemModel, - index: QtCore.QModelIndex, - ): - """Take the editor, read the given value and set it in the model. - - If the new formula is the same as the existing one, do not call setData - """ - dialog = editor.findChild(FormulaDialog) - if dialog.result() == QtWidgets.QDialog.Rejected: - # Cancel was clicked, do not store anything. - return - model.setData(index, dialog.formula, QtCore.Qt.EditRole) diff --git a/activity_browser/ui/dialogs/progress_dialog.py b/activity_browser/ui/dialogs/progress_dialog.py index 088193678..5dec1df83 100644 --- a/activity_browser/ui/dialogs/progress_dialog.py +++ b/activity_browser/ui/dialogs/progress_dialog.py @@ -1,6 +1,5 @@ from qtpy.QtWidgets import QProgressDialog -from activity_browser.app import application from activity_browser.mod.tqdm import qt_tqdm from activity_browser.mod.pyprind import qt_pyprind @@ -9,6 +8,8 @@ class ABProgressDialog(QProgressDialog): @classmethod def get_connected_dialog(cls, title: str) -> "ABProgressDialog": + from activity_browser.app import application + dialog = cls(application.main_window) dialog.setWindowTitle(title) dialog.setLabelText("Initializing") diff --git a/activity_browser/ui/dialogs/uncertainty.py b/activity_browser/ui/dialogs/uncertainty.py index 7978cc05c..0e86244a5 100644 --- a/activity_browser/ui/dialogs/uncertainty.py +++ b/activity_browser/ui/dialogs/uncertainty.py @@ -8,7 +8,6 @@ from stats_arrays import uncertainty_choices as uncertainty from stats_arrays.distributions import * -from activity_browser import app, app from activity_browser.ui.widgets.plot import ABPlot from activity_browser.bwutils.pedigree import PedigreeMatrix from activity_browser.bwutils.uncertainty import get_uncertainty_interface, EMPTY_UNCERTAINTY @@ -79,6 +78,8 @@ def update_uncertainty(self): """Update the uncertainty information of the relevant object, optionally including a pedigree update. """ + from activity_browser import app + self.amount_mean_test() if self.obj.data_type == "exchange": app.actions.ExchangeModify.run(self.obj.data, self.uncertainty_info) @@ -135,6 +136,8 @@ def amount_mean_test(self) -> None: """Asks if the 'amount' of the object should be updated to account for the user altering the loc/mean value. """ + from activity_browser import app + uc_type = self.field("uncertainty type") no_change = {UndefinedUncertainty.id, NoUncertainty.id} mean = float(self.field("loc")) diff --git a/activity_browser/ui/widgets/central.py b/activity_browser/ui/widgets/central.py index 0e5c789e2..dd4610980 100644 --- a/activity_browser/ui/widgets/central.py +++ b/activity_browser/ui/widgets/central.py @@ -2,8 +2,6 @@ from qtpy import QtWidgets -from activity_browser.app import signals - class CentralTabWidget(QtWidgets.QTabWidget): """ @@ -21,7 +19,12 @@ def __init__(self, *args): *args: Positional arguments passed to the parent QTabWidget. """ super().__init__(*args) - # Connect to the project changed signal to reset the current index to 0 + self.connect_signals() + + def connect_signals(self): + from activity_browser.app import signals + # this should be located in the app module + signals.project.changed.connect(self.reset) @property @@ -111,6 +114,7 @@ def connect_signals(self): - Connects the `tabCloseRequested` signal to the `tabClosed` method. - Connects the `project.changed` signal to the `deleteLater` method to clean up the widget. """ + from activity_browser.app import signals self.tabCloseRequested.connect(self.tabClosed) signals.project.changed.connect(self.deleteLater) From ad4834a66e8006006aa46a06316c699f16ac2e39 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 12 Nov 2025 11:01:21 +0100 Subject: [PATCH 112/267] Working bw dir switching --- activity_browser/app/pages/settings/startup.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/activity_browser/app/pages/settings/startup.py b/activity_browser/app/pages/settings/startup.py index 53fde243e..af160f93d 100644 --- a/activity_browser/app/pages/settings/startup.py +++ b/activity_browser/app/pages/settings/startup.py @@ -63,6 +63,7 @@ def connect_signals(self): # Emit changed signal when settings change self.bwdir_combo.currentTextChanged.connect(lambda: self.changed.emit()) + self.bwdir_combo.currentTextChanged.connect(self.show_virtual_projects) self.startup_project_combo.currentTextChanged.connect(lambda: self.changed.emit()) # --- Settings management methods --- # @@ -140,6 +141,15 @@ def remove_bwdir(self): self.bwdir_combo.setCurrentText(settings["startup"]["brightway_directory"]) self.bwdir_combo.removeItem(removed_index) + def show_virtual_projects(self): + """Show projects from the virtual Brightway directory.""" + virtual_projects = self.get_projects_from_path(self.bwdir_combo.currentText()) + startup = settings["startup"]["startup_project"] + + self.startup_project_combo.clear() + self.startup_project_combo.addItems(virtual_projects if virtual_projects else ["default"]) + self.startup_project_combo.setCurrentText(startup if startup in virtual_projects else "default") + def get_projects_from_path(self, path: str): """Get project names from a brightway directory.""" database_file = os.path.join(path, "projects.db") From b4bc910710bb0dc70eaf62fa509e9ccfd0786915 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 12 Nov 2025 11:27:00 +0100 Subject: [PATCH 113/267] Switch to dark mode on settings change --- activity_browser/app/main_window.py | 13 +++++++++++-- activity_browser/app/pages/settings/appearance.py | 3 +-- activity_browser/ui/core/application.py | 5 +++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/activity_browser/app/main_window.py b/activity_browser/app/main_window.py index 574e70085..eb77e71e8 100644 --- a/activity_browser/app/main_window.py +++ b/activity_browser/app/main_window.py @@ -1,7 +1,7 @@ from pathlib import Path from loguru import logger -from qtpy import QtCore, QtWidgets +from qtpy import QtCore, QtWidgets, QtGui import bw2data as bd from activity_browser import app @@ -102,7 +102,16 @@ def apply_settings(self, load=False): if not bd.projects.twofive: logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") app.actions.ProjectSwitch.set_warning_bar() - + + # Apply appearance settings + if app.settings["appearance"]["theme"] == "dark": + hint = QtCore.Qt.ColorScheme.Dark + elif app.settings["appearance"]["theme"] == "light": + hint = QtCore.Qt.ColorScheme.Light + else: + hint = QtCore.Qt.ColorScheme.Unknown + + app.application.styleHints().setColorScheme(hint) def connect_signals(self): app.signals.project.changed.connect(self.sync) diff --git a/activity_browser/app/pages/settings/appearance.py b/activity_browser/app/pages/settings/appearance.py index b7b5bd492..4cc4b43c0 100644 --- a/activity_browser/app/pages/settings/appearance.py +++ b/activity_browser/app/pages/settings/appearance.py @@ -12,7 +12,7 @@ class AppearanceSettingsChapter(BaseSettingsChapter): theme_map = { "default": "System default", "light": "Light theme", - "dark": "Dark theme compatibility", + "dark": "Dark theme", } def __init__(self, parent=None): @@ -39,7 +39,6 @@ def build_layout(self): theme_layout = QtWidgets.QGridLayout() theme_layout.addWidget(QtWidgets.QLabel("Theme:"), 0, 0) theme_layout.addWidget(self.theme_combo, 0, 1) - theme_layout.addWidget(QtWidgets.QLabel("(Requires restart)"), 0, 2) theme_group.setLayout(theme_layout) layout.addWidget(theme_group) diff --git a/activity_browser/ui/core/application.py b/activity_browser/ui/core/application.py index 45b0be424..674bdf050 100644 --- a/activity_browser/ui/core/application.py +++ b/activity_browser/ui/core/application.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from loguru import logger @@ -67,13 +68,13 @@ def check_palette(self, color_scheme): plt.style.use("dark_background") - # os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--force-dark-mode" + os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--force-dark-mode" else: palette = self.style().standardPalette() plt.style.use("default") - # os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "" + os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "" self.setPalette(palette) @property From 4be04242582cb92c3c1a183f3ae9b7f49019174b Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 12 Nov 2025 13:03:31 +0100 Subject: [PATCH 114/267] Integrate projects into settings --- .../project/project_create_template.py | 2 +- .../app/actions/project/project_delete.py | 3 + .../app/pages/settings/__init__.py | 11 +- .../app/pages/settings/project_manager.py | 230 ++++++++++++++++++ .../app/pages/settings/settings_page.py | 3 + activity_browser/static/icons/main/star.png | Bin 0 -> 1187 bytes activity_browser/ui/icons.py | 1 + 7 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 activity_browser/app/pages/settings/project_manager.py create mode 100644 activity_browser/static/icons/main/star.png diff --git a/activity_browser/app/actions/project/project_create_template.py b/activity_browser/app/actions/project/project_create_template.py index b08fedc08..937767f4b 100644 --- a/activity_browser/app/actions/project/project_create_template.py +++ b/activity_browser/app/actions/project/project_create_template.py @@ -20,7 +20,7 @@ class ProjectCreateTemplate(ABAction): package the project and save it there. Saving code copied from bw2data.backup. """ icon = app.application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) - text = "Create template for project" + text = "Create template from project" tool_tip = "Export project to file" @staticmethod diff --git a/activity_browser/app/actions/project/project_delete.py b/activity_browser/app/actions/project/project_delete.py index 0a645ee98..513b14454 100644 --- a/activity_browser/app/actions/project/project_delete.py +++ b/activity_browser/app/actions/project/project_delete.py @@ -91,6 +91,9 @@ def delete_project(name: str, delete_dir: bool): ds.delete_instance() + # THIS SHOULD NOT HAPPEN HERE BUT bw2data HAS NO SIGNALS FOR PROJECT DELETION + app.signals.project.deleted.emit(name) + class ProjectDeletionDialog(QtWidgets.QDialog): diff --git a/activity_browser/app/pages/settings/__init__.py b/activity_browser/app/pages/settings/__init__.py index 50844ed9f..bffc24319 100644 --- a/activity_browser/app/pages/settings/__init__.py +++ b/activity_browser/app/pages/settings/__init__.py @@ -1,5 +1,14 @@ # -*- coding: utf-8 -*- from .settings_page import SettingsPage from .base import BaseSettingsChapter +from .startup import StartupSettingsChapter +from .appearance import AppearanceSettingsChapter +from .project_manager import ProjectManagerSettingsChapter -__all__ = ["SettingsPage", "BaseSettingsChapter"] +__all__ = [ + "SettingsPage", + "BaseSettingsChapter", + "StartupSettingsChapter", + "AppearanceSettingsChapter", + "ProjectManagerSettingsChapter", +] diff --git a/activity_browser/app/pages/settings/project_manager.py b/activity_browser/app/pages/settings/project_manager.py new file mode 100644 index 000000000..7856f6ee0 --- /dev/null +++ b/activity_browser/app/pages/settings/project_manager.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- +from loguru import logger + +import pandas as pd +from qtpy import QtWidgets, QtGui + +import bw2data as bd +from bw2io import remote + +from activity_browser import app, ui +from activity_browser.bwutils.commontasks import get_templates +from activity_browser.ui import widgets, core + +from .base import BaseSettingsChapter + + +class ProjectManagerSettingsChapter(BaseSettingsChapter): + """Chapter for project and template management.""" + + def __init__(self, parent=None): + super().__init__(parent) + + self.tabs = QtWidgets.QTabWidget(self) + + self.project_model = ProjectModel(parent=self) + self.template_model = TemplateModel(parent=self) + + self.project_view = ProjectView(self) + self.project_view.setModel(self.project_model) + + self.template_view = TemplateView(self) + self.template_view.setModel(self.template_model) + + self.tabs.addTab(self.project_view, "Projects") + self.tabs.addTab(self.template_view, "Templates") + + self.build_layout() + self.connect_signals() + self.reset() + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.tabs) + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + def connect_signals(self): + """Connect signals and slots.""" + app.signals.project.changed.connect(self.sync) + app.signals.project.deleted.connect(self.sync) + + def sync(self): + """Sync project and template data.""" + df = self.build_project_df() + df.reset_index(drop=True, inplace=True) + self.project_model.set_dataframe(df) + self.project_view.resizeColumnToContents(1) + + df = self.build_template_df() + df.reset_index(drop=True, inplace=True) + self.template_model.set_dataframe(df) + self.template_view.resizeColumnToContents(1) + + def reset(self): + """Reset to initial values.""" + self.sync() + + def has_changes(self): + """Project manager doesn't have editable settings.""" + return False + + def set_settings(self): + """No settings to save for project manager.""" + pass + + def build_project_df(self) -> pd.DataFrame: + """Build DataFrame for projects.""" + data = [] + for proj_ds in sorted(bd.projects): + # if for any reason the project data is not a dictionary, log a warning and set it to an empty dict + if not isinstance(proj_ds.data, dict): + logger.warning(f"Project {proj_ds.name} has no data dictionary") + proj_ds.data = {} + + data.append({ + "name": proj_ds.name, + "path": proj_ds.dir, + "version": "Brightway25" if proj_ds.data.get("25", False) else "Legacy" + }) + + cols = ["name", "version", "path"] + return pd.DataFrame(data, columns=cols) + + def build_template_df(self) -> pd.DataFrame: + """Build DataFrame for templates.""" + data = [] + + templates = get_templates() + remote_templates = remote.get_projects() + + for name in sorted(templates): + data.append({ + "name": name, + "path": templates[name], + "remote": "No" + }) + + for name in sorted(remote_templates): + data.append({ + "name": name, + "path": remote_templates[name], + "remote": "Yes" + }) + + cols = ["name", "path", "remote"] + return pd.DataFrame(data, columns=cols) + + +class ProjectView(widgets.ABNewTreeView): + + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m, p: m.addMenu(p.get_project_new_menu(m)), + lambda m, p: m.addSeparator() if p.has_selection else None, + lambda m, p: m.add(app.actions.ProjectDuplicate, p.selected_project, + enable=p.single_selection) if p.single_selection else None, + lambda m, p: m.add(app.actions.ProjectCreateTemplate, p.selected_project, m.parent(), + enable=p.single_selection) if p.single_selection else None, + lambda m, p: m.add(app.actions.ProjectMigrate25, p.selected_project, + enable=(p.single_selection and p.is_legacy)) if p.single_selection and p.is_legacy else None, + lambda m, p: m.addSeparator() if p.has_selection else None, + lambda m, p: m.add(app.actions.ProjectDelete, p.selected_projects, + enable=p.has_selection) if p.has_selection else None, + ] + + def __init__(self, parent): + super().__init__(parent) + self.setSortingEnabled(True) + self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) + + def get_project_new_menu(self, parent): + """Get the ProjectNewMenu.""" + from activity_browser.app.menu_bar import ProjectNewMenu + return ProjectNewMenu(parent) + + @property + def selected_projects(self) -> list: + if not self.selectedIndexes(): + return [] + names = self.model().values_from_indices("name", self.selectedIndexes()) + return list(set(names)) + + @property + def selected_project(self): + return self.selected_projects[0] if self.single_selection else None + + @property + def single_selection(self): + return len(self.selected_projects) == 1 + + @property + def has_selection(self): + return len(self.selected_projects) > 0 + + @property + def is_legacy(self): + if not self.single_selection: + return False + index = self.selectedIndexes()[0] + return self.model().get(index, "version") == "Legacy" + + +class ProjectModel(core.ABTreeModel): + """Model for project data.""" + + def fontData(self, index): + """Provide font data for the model.""" + column_name = self.column_name(index) + + if column_name == "name": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font + + return None + + def decorationData(self, index): + """Provide icon decoration for the model.""" + column_name = self.column_name(index) + + if column_name == "name": + name = self.get(index, "name") + if name == app.settings["startup"]["startup_project"]: + return ui.icons.qicons.star + if name == bd.projects.current: + return ui.icons.qicons.forward + + return ui.icons.qicons.empty + + return None + + +class TemplateView(widgets.ABNewTreeView): + + class ContextMenu(widgets.ABMenu): + menuSetup = [] + + def __init__(self, parent): + super().__init__(parent) + self.setSortingEnabled(True) + self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + + +class TemplateModel(core.ABTreeModel): + """Model for template data.""" + + def fontData(self, index): + """Provide font data for the model.""" + column_name = self.column_name(index) + + if column_name == "name": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font + + return None + + diff --git a/activity_browser/app/pages/settings/settings_page.py b/activity_browser/app/pages/settings/settings_page.py index 2069ed30f..ed811d3c2 100644 --- a/activity_browser/app/pages/settings/settings_page.py +++ b/activity_browser/app/pages/settings/settings_page.py @@ -10,6 +10,7 @@ from .startup import StartupSettingsChapter from .appearance import AppearanceSettingsChapter +from .project_manager import ProjectManagerSettingsChapter class SettingsPage(QtWidgets.QWidget): @@ -35,11 +36,13 @@ def __init__(self, parent=None): # Create chapters self.startup_chapter = StartupSettingsChapter(self) self.appearance_chapter = AppearanceSettingsChapter(self) + self.project_manager_chapter = ProjectManagerSettingsChapter(self) # Add chapters to the stack self.chapters = [ ("Startup", self.startup_chapter), ("Appearance", self.appearance_chapter), + ("Projects", self.project_manager_chapter), ] for name, widget in self.chapters: diff --git a/activity_browser/static/icons/main/star.png b/activity_browser/static/icons/main/star.png new file mode 100644 index 0000000000000000000000000000000000000000..3269055490ae08c4c768ae044aa57c6cf279d66a GIT binary patch literal 1187 zcmV;U1YG-xP)7uw5>AqNZE(+~NNWq0l4Q6AZLb13A5-7CLg`}3`wQ2lQBN2&BlbkN* zzVJxzS zH3gJX%|HbZpi4wnecYqJFR z2e~#w7Zs4S`Gu_vyqhtW4dy}sn}(KqGt6}gD5bi9TbK*I3UrFdLUySBZ+TZh+U6Iw zX5dIhSRyS|1t_Ijfo;IE`fr?9zaIdew#2vuTmu$E{&&Q&MPN}xmJ@RV-X>Umg~3Pp zR!cIj>^|`V)gG@vaFwdNvzy5)zyNv*>;$F@0Lqn51MMOLB2pEh75KIQ;PUnhKNW0)G_%ysh5@-i+lL_k|W= z7cf%*5~ltO@KRll^}f_1YzM9sfJ|Dy3_RD6U!xPW2wQ>i0+34TUjSPYaZ7SijSwdZ zp5yr@80YK2$Ei4_Ia!PF0kB*EvSI%hIFy=C+5uXGy}&{NNLPIxc+H55$sjF48Ms~m z@>hWujCq(2)FM0seD9I`cfeCt9Bc+_5t@Lr9>{+Vlx*=^?+8ioJpYPq90U7NGT1TB zYD?^`jN&-YnUw&88O3p)(|T7}@BBJ{xk=meBJ#UUfGjG&NBL)#`-uQW0O2#YRS!@~ z^%A`B=+ipH;xW2y1^B%BRkOnIL;#P;mw`{T|7Q`$ZarXe53mdKb^IluuO`-uzz-Jj z%Fb_wfa3;0{{V*T#2g0xG>H454*|YP33420OAxmWIG;AQ4*`Bl2yO=0XAu7#f}hwV zIp&-S0KAd_)CzD4Xt5Bv1vm{nNRY43D2E}i6Ai#j0k4`8kI1(h__;yOQI`T-hyk&r z#j)P0iO5+3J_7!U$^E4h0SMkIxfcOof?!#iDe;K>oxo&7{&`md41^Nj1`g&;JOamI z;BE*@f7&=EE5YjOZyh)TyeuLU263!RM7{y~fpY}pYq6yYlvepkg69FRyT0mmFueiX z0KRrn0T+QiA~Iz#HWw3-p9rSr<+NkH{qJy5>>+6D QIcon: critical = create_path("context", "critical.png"), locked = create_path("main", "locked.png"), unlocked = create_path("main", "unlocked.png"), + star = create_path("main", "star.png"), ) From 1bc1b8b9998de868d0f08fd500e5af83ab8c7b65 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 12 Nov 2025 16:31:29 +0100 Subject: [PATCH 115/267] Refactor main window and settings management to support dynamic pane and page visibility --- activity_browser/__main__.py | 17 -- activity_browser/app/__init__.py | 2 + activity_browser/app/main_window.py | 57 +++--- activity_browser/app/menu_bar.py | 6 - activity_browser/app/pages/__init__.py | 8 +- .../app/pages/settings/startup.py | 54 +++++- activity_browser/app/panes/__init__.py | 27 +-- activity_browser/app/panes/project_manager.py | 163 ------------------ activity_browser/bwutils/settings.py | 2 + 9 files changed, 101 insertions(+), 235 deletions(-) delete mode 100644 activity_browser/app/panes/project_manager.py diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index c120c2759..3f88c55d0 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -91,16 +91,6 @@ def load_modules(self): thread.start() def load_layout(self): - from .ui.widgets import CentralTabWidget - from .app import pages, application - - central_widget = CentralTabWidget(application.main_window) - central_widget.addTab(pages.WelcomePage(), "Welcome") - central_widget.addTab(pages.ParametersPage(), "Parameters") - central_widget.addTab(pages.SettingsPage(), "Settings") - - application.main_window.setCentralWidget(central_widget) - self.load_finished() def load_finished(self): @@ -156,13 +146,6 @@ def run_activity_browser_no_launcher(): from .ui.widgets import CentralTabWidget from .app import panes, pages, application, metadata - from activity_browser import signals - - central_widget = CentralTabWidget(application.main_window) - central_widget.addTab(pages.WelcomePage(), "Welcome") - central_widget.addTab(pages.ParametersPage(), "Parameters") - - application.main_window.setCentralWidget(central_widget) application.main_window.sync() application.main_window.show() diff --git a/activity_browser/app/__init__.py b/activity_browser/app/__init__.py index e192159ff..b4c28d862 100644 --- a/activity_browser/app/__init__.py +++ b/activity_browser/app/__init__.py @@ -17,6 +17,8 @@ # modules dependent on application and signals from . import actions +from . import panes +from . import pages main_window = MainWindow() application.main_window = main_window diff --git a/activity_browser/app/main_window.py b/activity_browser/app/main_window.py index eb77e71e8..edf3eb424 100644 --- a/activity_browser/app/main_window.py +++ b/activity_browser/app/main_window.py @@ -5,6 +5,7 @@ import bw2data as bd from activity_browser import app +from activity_browser.ui import widgets class MainWindow(QtWidgets.QMainWindow): @@ -34,35 +35,24 @@ def __init__(self, parent=None): self.menu_bar = MenuBar(self) self.setMenuBar(self.menu_bar) + self.central_widget = widgets.CentralTabWidget(self) + self.setCentralWidget(self.central_widget) + self.connect_signals() def sync(self): - """ - Synchronizes the main window layout with the current Brightway2 project. - - This method clears existing panes, initializes default panes, and arranges them - in the main window. Hidden panes are set to be invisible, and the first pane is - raised to the top. The window title is updated to reflect the current project. - - Steps: - - Clear all existing panes. - - Create and add default panes as dock widgets. - - Hide panes that are marked as hidden. - - Tabify dock widgets for better organization. - - Raise the first dock widget to the top. - - Update the window title with the current project name. - - Args: - self: The instance of the MainWindow class. - """ - from activity_browser.app import panes + self.sync_panes() + self.sync_pages() - # Clear all existing panes in the main window + self.setWindowTitle(f"Activity Browser - {bd.projects.current}") + + def sync_panes(self): self.clearPanes() dws = [] + # Iterate through the default panes and add them as dock widgets - for pane_class in panes.default_panes: + for pane_name, pane_class in app.panes.base_panes.items(): pane = pane_class(parent=self) dockwidget = pane.getDockWidget(self) dws.append(dockwidget) @@ -73,7 +63,7 @@ def sync(self): self.menu_bar.view_menu.addAction(dockwidget.toggleViewAction()) # Hide the dock widget if it is marked as hidden - if pane_class in panes.hidden_panes: + if pane_name not in app.settings["startup"]["shown_panes"]: dockwidget.hide() # Synchronize the pane @@ -87,9 +77,26 @@ def sync(self): # Raise the first dock widget to the top dws[0].raise_() - - # Update the window title to reflect the current project - self.setWindowTitle(f"Activity Browser - {bd.projects.current}") + + def sync_pages(self): + """ + Synchronizes the central widget pages with the shown_pages setting. + + This method clears existing pages and adds only those pages that are + configured to be shown at startup. + """ + # Clear existing pages + while self.central_widget.count() > 0: + self.central_widget.removeTab(0) + + # Add pages based on shown_pages setting + shown_pages = app.settings["startup"].get("shown_pages", []) + + for page_name in shown_pages: + if page_name in app.pages.base_pages: + page_class = app.pages.base_pages[page_name] + page_instance = page_class() + self.central_widget.addTab(page_instance, page_name) def apply_settings(self, load=False): diff --git a/activity_browser/app/menu_bar.py b/activity_browser/app/menu_bar.py index eb3af9300..d010e63fe 100644 --- a/activity_browser/app/menu_bar.py +++ b/activity_browser/app/menu_bar.py @@ -47,9 +47,6 @@ def __init__(self, parent=None) -> None: self.import_proj_action = app.actions.ProjectImport.get_QAction() self.export_proj_action = app.actions.ProjectExport.get_QAction() - self.manage_settings_action = app.actions.SettingsWizardOpen.get_QAction() - self.manage_projects_action = app.actions.ProjectManagerOpen.get_QAction() - self.addMenu(ProjectSelectionMenu(self)) self.addMenu(ProjectNewMenu(self)) self.addAction(self.dup_proj_action) @@ -62,9 +59,6 @@ def __init__(self, parent=None) -> None: self.addMenu(ExportDatabaseMenu(self)) self.addSeparator() self.addMenu(ImportICMenu(self)) - self.addSeparator() - self.addAction(self.manage_settings_action) - self.addAction(self.manage_projects_action) class ProjectNewMenu(QtWidgets.QMenu): diff --git a/activity_browser/app/pages/__init__.py b/activity_browser/app/pages/__init__.py index b8e34b0ad..1f360e945 100644 --- a/activity_browser/app/pages/__init__.py +++ b/activity_browser/app/pages/__init__.py @@ -5,4 +5,10 @@ from .lca_results import LCAResultsPage from .parameters import ParametersPage from .metadatastore import MetaDataStorePage -from .settings import SettingsPage, BaseSettingsChapter +from .settings import SettingsPage + +base_pages = { + "Welcome": WelcomePage, + "Parameters": ParametersPage, + "Settings": SettingsPage, +} diff --git a/activity_browser/app/pages/settings/startup.py b/activity_browser/app/pages/settings/startup.py index af160f93d..aa5b3c7f6 100644 --- a/activity_browser/app/pages/settings/startup.py +++ b/activity_browser/app/pages/settings/startup.py @@ -8,7 +8,7 @@ from bw2data import projects -from activity_browser.app import settings +from activity_browser.app import settings, panes, pages from .base import BaseSettingsChapter @@ -26,6 +26,18 @@ def __init__(self, parent=None): # Startup project self.startup_project_combo = QtWidgets.QComboBox() + # Shown panes checkboxes + self.pane_checkboxes = {} + self.available_panes = list(panes.base_panes.keys()) + for pane_name in self.available_panes: + self.pane_checkboxes[pane_name] = QtWidgets.QCheckBox(pane_name) + + # Shown pages checkboxes + self.page_checkboxes = {} + self.available_pages = list(pages.base_pages.keys()) + for page_name in self.available_pages: + self.page_checkboxes[page_name] = QtWidgets.QCheckBox(page_name) + self.build_layout() self.connect_signals() self.reset() @@ -50,8 +62,24 @@ def build_layout(self): project_layout.addWidget(self.startup_project_combo, 0, 1) project_group.setLayout(project_layout) + # Shown panes section + panes_group = QtWidgets.QGroupBox("Panes shown at startup") + panes_layout = QtWidgets.QVBoxLayout() + for pane_name in self.available_panes: + panes_layout.addWidget(self.pane_checkboxes[pane_name]) + panes_group.setLayout(panes_layout) + + # Shown pages section + pages_group = QtWidgets.QGroupBox("Pages shown at startup") + pages_layout = QtWidgets.QVBoxLayout() + for page_name in self.available_pages: + pages_layout.addWidget(self.page_checkboxes[page_name]) + pages_group.setLayout(pages_layout) + layout.addWidget(bwdir_group) layout.addWidget(project_group) + layout.addWidget(panes_group) + layout.addWidget(pages_group) layout.addStretch() self.setLayout(layout) @@ -65,6 +93,12 @@ def connect_signals(self): self.bwdir_combo.currentTextChanged.connect(lambda: self.changed.emit()) self.bwdir_combo.currentTextChanged.connect(self.show_virtual_projects) self.startup_project_combo.currentTextChanged.connect(lambda: self.changed.emit()) + + # Connect checkboxes + for checkbox in self.pane_checkboxes.values(): + checkbox.stateChanged.connect(lambda: self.changed.emit()) + for checkbox in self.page_checkboxes.values(): + checkbox.stateChanged.connect(lambda: self.changed.emit()) # --- Settings management methods --- # def reset(self): @@ -76,6 +110,16 @@ def reset(self): self.startup_project_combo.clear() self.startup_project_combo.addItems(self.get_projects_from_path(settings["startup"]["brightway_directory"])) self.startup_project_combo.setCurrentText(settings["startup"]["startup_project"]) + + # Set pane checkboxes + shown_panes = settings["startup"].get("shown_panes", []) + for pane_name, checkbox in self.pane_checkboxes.items(): + checkbox.setChecked(pane_name in shown_panes) + + # Set page checkboxes + shown_pages = settings["startup"].get("shown_pages", []) + for page_name, checkbox in self.page_checkboxes.items(): + checkbox.setChecked(page_name in shown_pages) def has_changes(self): """Check if there are unsaved changes.""" @@ -83,11 +127,15 @@ def has_changes(self): 'brightway_directory': self.bwdir_combo.currentText(), 'saved_brightway_directories': [self.bwdir_combo.itemText(i) for i in range(self.bwdir_combo.count())], 'startup_project': self.startup_project_combo.currentText(), + 'shown_panes': [name for name, cb in self.pane_checkboxes.items() if cb.isChecked()], + 'shown_pages': [name for name, cb in self.page_checkboxes.items() if cb.isChecked()], } initial_state = { 'brightway_directory': settings["startup"]["brightway_directory"], 'saved_brightway_directories': settings["startup"].get("saved_brightway_directories", []), 'startup_project': settings["startup"]["startup_project"], + 'shown_panes': settings["startup"].get("shown_panes", []), + 'shown_pages': settings["startup"].get("shown_pages", []), } return current_state != initial_state @@ -97,6 +145,10 @@ def set_settings(self): settings["startup"]["brightway_directory"] = self.bwdir_combo.currentText() settings["startup"]["saved_brightway_directories"] = [self.bwdir_combo.itemText(i) for i in range(self.bwdir_combo.count())] settings["startup"]["startup_project"] = self.startup_project_combo.currentText() + + # Save shown panes and pages + settings["startup"]["shown_panes"] = [name for name, cb in self.pane_checkboxes.items() if cb.isChecked()] + settings["startup"]["shown_pages"] = [name for name, cb in self.page_checkboxes.items() if cb.isChecked()] # --- Helper methods --- # def browse_bwdir(self): diff --git a/activity_browser/app/panes/__init__.py b/activity_browser/app/panes/__init__.py index adc5b1fdc..a1eaf0974 100644 --- a/activity_browser/app/panes/__init__.py +++ b/activity_browser/app/panes/__init__.py @@ -1,28 +1,11 @@ from .database_explorer import DatabaseExplorerPane from .database_products import DatabaseProductsPane -from .project_manager import ProjectManagerPane from .databases import DatabasesPane from .impact_categories import ImpactCategoriesPane from .calculation_setups import CalculationSetupsPane - -registered_panes = [ - DatabaseExplorerPane, - DatabaseProductsPane, - ProjectManagerPane, - DatabasesPane, - ImpactCategoriesPane, - CalculationSetupsPane, -] - -shown_panes = [ - DatabasesPane, - ImpactCategoriesPane, - CalculationSetupsPane, -] - -hidden_panes = [ - ProjectManagerPane, -] - -default_panes = shown_panes + hidden_panes +base_panes = { + "Databases": DatabasesPane, + "Impact Categories": ImpactCategoriesPane, + "Calculation Setups": CalculationSetupsPane, +} diff --git a/activity_browser/app/panes/project_manager.py b/activity_browser/app/panes/project_manager.py deleted file mode 100644 index 962b9e316..000000000 --- a/activity_browser/app/panes/project_manager.py +++ /dev/null @@ -1,163 +0,0 @@ -from loguru import logger - -import pandas as pd -from qtpy import QtWidgets, QtCore - -import bw2data as bd -from bw2io import remote - -from activity_browser import app, ui -from activity_browser.bwutils.commontasks import get_templates -from activity_browser.settings import ab_settings -from activity_browser.ui import widgets - - - - - -class ProjectManagerPane(widgets.ABAbstractPane): - title = "Project Manager" - unique = True - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Project Manager") - - self.tabs = QtWidgets.QTabWidget(self) - - self.project_model = widgets.ABItemModel(self) - self.project_model.dataItemClass = ProjectItem - - self.template_model = widgets.ABItemModel(self) - self.template_model.dataItemClass = TemplateItem - - self.project_view = ProjectView(self) - self.project_view.setModel(self.project_model) - - self.template_view = TemplateView(self) - self.template_view.setModel(self.template_model) - - self.sync() - - self.tabs.addTab(self.project_view, "Projects") - self.tabs.addTab(self.template_view, "Templates") - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.tabs) - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - - # connect signals - app.signals.project.changed.connect(self.sync) - app.signals.project.deleted.connect(self.sync) - - def sync(self): - self.project_model.setDataFrame(self.build_project_df()) - self.template_model.setDataFrame(self.build_template_df()) - - def build_project_df(self) -> pd.DataFrame: - data = {} - for proj_ds in sorted(bd.projects): - # if for any reason the project data is not a dictionary, log a warning and set it to an empty dict - if not isinstance(proj_ds.data, dict): - logger.warning(f"Project {proj_ds.name} has no data dictionary") - proj_ds.data = {} - - data[proj_ds.name] = { - "Name": proj_ds.name, - "Path": proj_ds.dir, - "Version": "Brightway25" if proj_ds.data.get("25", False) else "Legacy" - } - - return pd.DataFrame.from_dict(data, orient="index") - - def build_template_df(self) -> pd.DataFrame: - data = {} - - templates = get_templates() - remote_templates = remote.get_projects() - - for name in sorted(templates): - data[name] = { - "Name": name, - "Path": templates[name], - "Remote": "No" - } - - for name in sorted(remote_templates): - data[name] = { - "Name": name, - "Path": remote_templates[name], - "Remote": "Yes" - } - - return pd.DataFrame.from_dict(data, orient="index") - - -class ProjectView(widgets.ABTreeView): - - class ContextMenu(widgets.ABTreeView.ContextMenu): - def __init__(self, pos, view: "FunctionView"): - from activity_browser.app.menu_bar import ProjectNewMenu - - super().__init__(pos, view) - items = list({index.internalPointer() for index in view.selectedIndexes()}) - - self.addMenu(ProjectNewMenu(self)) - - if len(items) == 0: - return - - if len(items) == 1: - self.dup_project = app.actions.ProjectDuplicate.get_QAction(items[0]["Name"]) - self.template_project = app.actions.ProjectCreateTemplate.get_QAction(items[0]["Name"], view.parent()) - self.addAction(self.dup_project) - self.addAction(self.template_project) - - if len(items) == 1 and len([i for i in items if i["Version"] == "Legacy"]) == 1: - self.migrate_project = app.actions.ProjectMigrate25.get_QAction(items[0]["Name"]) - self.addAction(self.migrate_project) - - self.del_project = app.actions.ProjectDelete.get_QAction(view.selected_projects) - self.addAction(self.del_project) - - - def __init__(self, parent: ProjectManagerPane): - super().__init__(parent) - self.setSortingEnabled(True) - self.setSelectionBehavior(ui.widgets.ABTreeView.SelectionBehavior.SelectRows) - self.setSelectionMode(ui.widgets.ABTreeView.SelectionMode.ExtendedSelection) - - - @property - def selected_projects(self) -> [str]: - items = [i.internalPointer() for i in self.selectedIndexes() if isinstance(i.internalPointer(), ProjectItem)] - return list({item["Name"] for item in items if item["Name"] is not None}) - - -class ProjectItem(widgets.ABDataItem): - def decorationData(self, col, key): - if col != 0: - return - return ui.icons.qicons.forward if self["Name"] == ab_settings.startup_project else ui.icons.QIcons.forward - - -class TemplateView(widgets.ABTreeView): - - class ContextMenu(widgets.ABTreeView.ContextMenu): - def __init__(self, pos, view: "FunctionView"): - super().__init__(pos, view) - - items = list({index.internalPointer() for index in view.selectedIndexes()}) - - def __init__(self, parent: ProjectManagerPane): - super().__init__(parent) - self.setSortingEnabled(True) - self.setSelectionBehavior(ui.widgets.ABTreeView.SelectionBehavior.SelectRows) - - -class TemplateItem(widgets.ABDataItem): - def decorationData(self, col, key): - if col != 0: - return - return ui.icons.qicons.forward if self["Name"] == ab_settings.startup_project else ui.icons.QIcons.forward diff --git a/activity_browser/bwutils/settings.py b/activity_browser/bwutils/settings.py index d98ed0901..eea79dda8 100644 --- a/activity_browser/bwutils/settings.py +++ b/activity_browser/bwutils/settings.py @@ -11,6 +11,8 @@ "brightway_directory": str(bd.projects._base_data_dir), "saved_brightway_directories": [str(bd.projects._base_data_dir)], "startup_project": "default", + "shown_panes": ["Databases", "Impact Categories", "Calculation Setups"], + "shown_pages": ["Welcome", "Parameters", "Settings"], }, "appearance": { "theme": "default", From 2781de120710b2d813e68fe2137ccc329ca264ac Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 12 Nov 2025 17:08:21 +0100 Subject: [PATCH 116/267] Tabs stuff --- activity_browser/app/main_window.py | 127 +++++++++++++++++-- activity_browser/app/menu_bar.py | 22 ++++ activity_browser/ui/widgets/abstract_page.py | 8 ++ activity_browser/ui/widgets/central.py | 92 ++++++++++++-- 4 files changed, 228 insertions(+), 21 deletions(-) create mode 100644 activity_browser/ui/widgets/abstract_page.py diff --git a/activity_browser/app/main_window.py b/activity_browser/app/main_window.py index edf3eb424..6fe28e328 100644 --- a/activity_browser/app/main_window.py +++ b/activity_browser/app/main_window.py @@ -36,7 +36,18 @@ def __init__(self, parent=None): self.setMenuBar(self.menu_bar) self.central_widget = widgets.CentralTabWidget(self) + self.central_widget.setTabsClosable(True) self.setCentralWidget(self.central_widget) + + # Initialize all base pages upfront (name -> widget instance) + self.base_pages = {} + for page_name, page_class in app.pages.base_pages.items(): + page_instance = page_class() + page_instance.setObjectName(page_name) + self.base_pages[page_name] = page_instance + + # Connect tab close signal + self.central_widget.tabCloseRequested.connect(self._on_tab_close_requested) self.connect_signals() @@ -82,21 +93,117 @@ def sync_pages(self): """ Synchronizes the central widget pages with the shown_pages setting. - This method clears existing pages and adds only those pages that are - configured to be shown at startup. + This method shows only those pages that are configured to be shown at startup. + Pages are pre-initialized and just added/removed from tabs. """ - # Clear existing pages + # Get shown pages from settings + shown_pages = app.settings["startup"].get("shown_pages", []) + + # Remove all pages from tabs first while self.central_widget.count() > 0: self.central_widget.removeTab(0) - # Add pages based on shown_pages setting - shown_pages = app.settings["startup"].get("shown_pages", []) - + # Add only the pages that should be shown for page_name in shown_pages: - if page_name in app.pages.base_pages: - page_class = app.pages.base_pages[page_name] - page_instance = page_class() - self.central_widget.addTab(page_instance, page_name) + if page_name in self.base_pages: + page_instance = self.base_pages[page_name] + # Base pages should show minimize button instead of close + self.central_widget.addTab(page_instance, page_name, show_minimize=True) + + def show_page(self, page_name: str): + """ + Show a page by adding it to the tabs. + + Args: + page_name: The name of the page to show + """ + if page_name not in self.base_pages: + return + + page_widget = self.base_pages[page_name] + + # Check if page is already in tabs + index = self.central_widget.indexOf(page_widget) + if index >= 0: + # Already shown, just switch to it + self.central_widget.setCurrentIndex(index) + else: + # Add to tabs with minimize button + self.central_widget.addTab(page_widget, page_name, show_minimize=True) + self.central_widget.setCurrentWidget(page_widget) + + def hide_page(self, page_name: str): + """ + Hide a page by removing it from the tabs (but not destroying it). + + Args: + page_name: The name of the page to hide + """ + if page_name not in self.base_pages: + return + + page_widget = self.base_pages[page_name] + index = self.central_widget.indexOf(page_widget) + if index >= 0: + self.central_widget.removeTab(index) + + def toggle_page(self, page_name: str): + """ + Toggle a page shown/hidden. + + Args: + page_name: The name of the page to toggle + """ + if page_name not in self.base_pages: + return + + page_widget = self.base_pages[page_name] + index = self.central_widget.indexOf(page_widget) + + if index >= 0: + # Page is shown, hide it + self.hide_page(page_name) + else: + # Page is hidden, show it + self.show_page(page_name) + + def is_page_visible(self, page_name: str) -> bool: + """ + Check if a page is currently visible in the tabs. + + Args: + page_name: The name of the page to check + + Returns: + bool: True if the page is visible, False otherwise + """ + if page_name not in self.base_pages: + return False + + page_widget = self.base_pages[page_name] + return self.central_widget.indexOf(page_widget) >= 0 + + def _on_tab_close_requested(self, index: int): + """ + Handle when user clicks the close button on a tab. + For base pages, we just hide them instead of destroying them. + + Args: + index: The index of the tab to close + """ + widget = self.central_widget.widget(index) + if widget is None: + return + + # Check if this is a base page + page_name = widget.objectName() + if page_name in self.base_pages: + # Just remove from tabs, don't destroy + self.central_widget.removeTab(index) + else: + # For non-base pages, remove and destroy + self.central_widget.removeTab(index) + widget.deleteLater() def apply_settings(self, load=False): diff --git a/activity_browser/app/menu_bar.py b/activity_browser/app/menu_bar.py index d010e63fe..cca441e39 100644 --- a/activity_browser/app/menu_bar.py +++ b/activity_browser/app/menu_bar.py @@ -116,6 +116,28 @@ class ViewMenu(QtWidgets.QMenu): def __init__(self, parent=None) -> None: super().__init__(parent) self.setTitle("&View") + + + # Populate pages + self.page_actions = {} + for page_name in app.pages.base_pages.keys(): + action = QtWidgets.QAction(page_name, self) + action.setCheckable(True) + action.triggered.connect(lambda checked, name=page_name: app.main_window.toggle_page(name)) + # Update checked state when menu is about to show + self.page_actions[page_name] = action + self.addAction(action) + + # Update the checked state when menu is about to show + self.aboutToShow.connect(self.update_page_actions) + + self.addSeparator() + + def update_page_actions(self): + """Update the checked state of page actions based on which pages are visible.""" + for page_name, action in self.page_actions.items(): + is_visible = app.main_window.is_page_visible(page_name) + action.setChecked(is_visible) class CalculateMenu(QtWidgets.QMenu): diff --git a/activity_browser/ui/widgets/abstract_page.py b/activity_browser/ui/widgets/abstract_page.py new file mode 100644 index 000000000..6e017208a --- /dev/null +++ b/activity_browser/ui/widgets/abstract_page.py @@ -0,0 +1,8 @@ +from qtpy import QtWidgets + + +class ABAbstractPage(QtWidgets.QWidget): + + def toggleViewAction(self, main_window): + """Return the toggle view action for this page.""" + return diff --git a/activity_browser/ui/widgets/central.py b/activity_browser/ui/widgets/central.py index dd4610980..f1ee9c01e 100644 --- a/activity_browser/ui/widgets/central.py +++ b/activity_browser/ui/widgets/central.py @@ -1,6 +1,16 @@ from loguru import logger -from qtpy import QtWidgets +from qtpy import QtWidgets, QtCore +from .dock_widget import CloseButton, MinimizeButton + + +class CentralTabBar(QtWidgets.QTabBar): + """Custom tab bar for the CentralTabWidget.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setMovable(True) + self.setDocumentMode(True) class CentralTabWidget(QtWidgets.QTabWidget): @@ -16,16 +26,79 @@ def __init__(self, *args): Initialize the CentralTabWidget. Args: - *args: Positional arguments passed to the parent QTabWidget. + *args: Additional positional arguments passed to the parent QTabWidget. """ super().__init__(*args) - self.connect_signals() - - def connect_signals(self): - from activity_browser.app import signals - # this should be located in the app module - signals.project.changed.connect(self.reset) + # Use custom tab bar with close/minimize buttons + self.setTabBar(CentralTabBar(self)) + + def addTab(self, widget, label, show_minimize=False): + """Override addTab to add custom buttons to each tab. + + Args: + widget: The widget to add as a tab + label: The label for the tab + show_minimize: If True, show minimize button; if False, show close button + """ + index = super().addTab(widget, label) + self._add_tab_buttons(index, show_minimize) + return index + + def insertTab(self, index, widget, label, show_minimize=False): + """Override insertTab to add custom buttons to each tab. + + Args: + index: The index at which to insert the tab + widget: The widget to add as a tab + label: The label for the tab + show_minimize: If True, show minimize button; if False, show close button + """ + index = super().insertTab(index, widget, label) + self._add_tab_buttons(index, show_minimize) + return index + + def _add_tab_buttons(self, index, show_minimize=False): + """Add close OR minimize button to a tab (mutually exclusive). + + Args: + index: The index of the tab + show_minimize: If True, show minimize button; otherwise show close button + """ + widget = self.widget(index) + if not widget: + return + + # Create a widget to hold the button + button_widget = QtWidgets.QWidget() + button_layout = QtWidgets.QHBoxLayout(button_widget) + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.setSpacing(2) + + # Add either minimize or close button (mutually exclusive) + if show_minimize: + minimize_btn = MinimizeButton(button_widget) + minimize_btn.clicked.connect(lambda w=widget: self._minimize_tab_by_widget(w)) + button_layout.addWidget(minimize_btn) + else: + close_btn = CloseButton(button_widget) + close_btn.clicked.connect(lambda w=widget: self._close_tab_by_widget(w)) + button_layout.addWidget(close_btn) + + # Set the button widget on the tab + self.tabBar().setTabButton(index, QtWidgets.QTabBar.ButtonPosition.RightSide, button_widget) + + def _close_tab_by_widget(self, widget): + """Handle close button click using the widget reference.""" + index = self.indexOf(widget) + if index >= 0: + self.tabCloseRequested.emit(index) + + def _minimize_tab_by_widget(self, widget): + """Handle minimize button click using the widget reference.""" + index = self.indexOf(widget) + if index >= 0: + self.tabCloseRequested.emit(index) @property def groups(self): @@ -78,9 +151,6 @@ def addToGroup(self, group: str, page: QtWidgets.QWidget): group.setCurrentIndex(index) page.deleteLater() # Clean up the newly created page since it already exists - def reset(self): - self.setCurrentIndex(0) - class GroupTabWidget(QtWidgets.QTabWidget): """ From fe3591820f270986d67760bcb823145d5aa11348 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 13 Nov 2025 11:16:49 +0100 Subject: [PATCH 117/267] Better tab widgets --- activity_browser/ui/widgets/__init__.py | 2 + activity_browser/ui/widgets/buttons.py | 63 ++++++++++++++ activity_browser/ui/widgets/central.py | 98 +--------------------- activity_browser/ui/widgets/dock_widget.py | 87 ++----------------- activity_browser/ui/widgets/tab_widget.py | 61 ++++++++++++++ 5 files changed, 135 insertions(+), 176 deletions(-) create mode 100644 activity_browser/ui/widgets/buttons.py create mode 100644 activity_browser/ui/widgets/tab_widget.py diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index a19b256d2..7177df41a 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -22,3 +22,5 @@ from .menu import ABMenu from .drop_overlay import ABDropOverlay from .tree_view import ABNewTreeView +from .buttons import ABCloseButton, ABMinimizeButton +from .tab_widget import ABTabWidget diff --git a/activity_browser/ui/widgets/buttons.py b/activity_browser/ui/widgets/buttons.py new file mode 100644 index 000000000..6bce9795f --- /dev/null +++ b/activity_browser/ui/widgets/buttons.py @@ -0,0 +1,63 @@ +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt + + +class ABCloseButton(QtWidgets.QWidget): + """Custom close button with hover effect.""" + clicked: QtCore.SignalInstance = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + + + self.label = QtWidgets.QLabel("×", self) + + self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) + self.label.setAlignment(Qt.AlignCenter) + self.label.setFixedSize(16, 16) + self.label.mousePressEvent = lambda event: self.clicked.emit() + + self.label.setStyleSheet(""" + QLabel { + border-radius: 8px; + background-color: transparent; + } + QLabel:hover { + background-color: rgba(255, 0, 0, 0.5); + } + """) + + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(5, 0, 0, 0) + layout.addWidget(self.label) + self.setLayout(layout) + + +class ABMinimizeButton(QtWidgets.QWidget): + """Custom close button with hover effect.""" + clicked: QtCore.SignalInstance = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + + self.label = QtWidgets.QLabel("-", self) + + self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) + self.label.setAlignment(Qt.AlignCenter) + self.label.setFixedSize(16, 16) + self.label.mousePressEvent = lambda event: self.clicked.emit() + + self.setStyleSheet(""" + QLabel { + border-radius: 8px; + background-color: transparent; + } + QLabel:hover { + background-color: rgba(42, 157, 244, 0.5); + } + """) + + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(5, 0, 0, 0) + layout.addWidget(self.label) + self.setLayout(layout) diff --git a/activity_browser/ui/widgets/central.py b/activity_browser/ui/widgets/central.py index f1ee9c01e..4829b7239 100644 --- a/activity_browser/ui/widgets/central.py +++ b/activity_browser/ui/widgets/central.py @@ -1,19 +1,11 @@ from loguru import logger -from qtpy import QtWidgets, QtCore -from .dock_widget import CloseButton, MinimizeButton +from qtpy import QtWidgets +from .tab_widget import ABTabWidget -class CentralTabBar(QtWidgets.QTabBar): - """Custom tab bar for the CentralTabWidget.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setMovable(True) - self.setDocumentMode(True) - -class CentralTabWidget(QtWidgets.QTabWidget): +class CentralTabWidget(ABTabWidget): """ A custom QTabWidget that manages groups of tabs and their associated pages. @@ -21,85 +13,6 @@ class CentralTabWidget(QtWidgets.QTabWidget): and ensuring that each page has a unique object name. """ - def __init__(self, *args): - """ - Initialize the CentralTabWidget. - - Args: - *args: Additional positional arguments passed to the parent QTabWidget. - """ - super().__init__(*args) - - # Use custom tab bar with close/minimize buttons - self.setTabBar(CentralTabBar(self)) - - def addTab(self, widget, label, show_minimize=False): - """Override addTab to add custom buttons to each tab. - - Args: - widget: The widget to add as a tab - label: The label for the tab - show_minimize: If True, show minimize button; if False, show close button - """ - index = super().addTab(widget, label) - self._add_tab_buttons(index, show_minimize) - return index - - def insertTab(self, index, widget, label, show_minimize=False): - """Override insertTab to add custom buttons to each tab. - - Args: - index: The index at which to insert the tab - widget: The widget to add as a tab - label: The label for the tab - show_minimize: If True, show minimize button; if False, show close button - """ - index = super().insertTab(index, widget, label) - self._add_tab_buttons(index, show_minimize) - return index - - def _add_tab_buttons(self, index, show_minimize=False): - """Add close OR minimize button to a tab (mutually exclusive). - - Args: - index: The index of the tab - show_minimize: If True, show minimize button; otherwise show close button - """ - widget = self.widget(index) - if not widget: - return - - # Create a widget to hold the button - button_widget = QtWidgets.QWidget() - button_layout = QtWidgets.QHBoxLayout(button_widget) - button_layout.setContentsMargins(0, 0, 0, 0) - button_layout.setSpacing(2) - - # Add either minimize or close button (mutually exclusive) - if show_minimize: - minimize_btn = MinimizeButton(button_widget) - minimize_btn.clicked.connect(lambda w=widget: self._minimize_tab_by_widget(w)) - button_layout.addWidget(minimize_btn) - else: - close_btn = CloseButton(button_widget) - close_btn.clicked.connect(lambda w=widget: self._close_tab_by_widget(w)) - button_layout.addWidget(close_btn) - - # Set the button widget on the tab - self.tabBar().setTabButton(index, QtWidgets.QTabBar.ButtonPosition.RightSide, button_widget) - - def _close_tab_by_widget(self, widget): - """Handle close button click using the widget reference.""" - index = self.indexOf(widget) - if index >= 0: - self.tabCloseRequested.emit(index) - - def _minimize_tab_by_widget(self, widget): - """Handle minimize button click using the widget reference.""" - index = self.indexOf(widget) - if index >= 0: - self.tabCloseRequested.emit(index) - @property def groups(self): """ @@ -152,7 +65,7 @@ def addToGroup(self, group: str, page: QtWidgets.QWidget): page.deleteLater() # Clean up the newly created page since it already exists -class GroupTabWidget(QtWidgets.QTabWidget): +class GroupTabWidget(ABTabWidget): """ A custom QTabWidget that represents a group of tabs. @@ -169,9 +82,6 @@ def __init__(self, name: str, *args): *args: Additional positional arguments passed to the parent QTabWidget. """ super().__init__(*args) - self.setMovable(True) # Allow tabs to be rearranged. - self.setTabsClosable(True) # Allow tabs to be closed. - self.setDocumentMode(True) # Enable document mode for a more modern appearance. self.setObjectName(name) # Set the object name for the widget. diff --git a/activity_browser/ui/widgets/dock_widget.py b/activity_browser/ui/widgets/dock_widget.py index 1917d89f1..df428393c 100644 --- a/activity_browser/ui/widgets/dock_widget.py +++ b/activity_browser/ui/widgets/dock_widget.py @@ -1,6 +1,8 @@ -from qtpy import QtWidgets, QtCore, QtGui +from qtpy import QtWidgets, QtGui from qtpy.QtCore import Qt +from .buttons import ABCloseButton, ABMinimizeButton + class HideMode: Close = 1 @@ -32,10 +34,10 @@ def setWidget(self, widget): def button(self): if self._hide_mode == HideMode.Close: - button = CloseButton(self) + button = ABCloseButton(self) button.clicked.connect(self.close) else: - button = MinimizeButton(self) + button = ABMinimizeButton(self) button.clicked.connect(self.hide) return button @@ -64,82 +66,3 @@ def set_button(self, button): w.deleteLater() -class CloseButton(QtWidgets.QWidget): - """Custom close button with hover effect.""" - clicked: QtCore.SignalInstance = QtCore.Signal() - - def __init__(self, parent=None): - super().__init__(parent) - - - self.label = QtWidgets.QLabel("×", self) - - self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) - self.label.setAlignment(Qt.AlignCenter) - self.label.setFixedSize(16, 16) - self.label.mousePressEvent = lambda event: self.clicked.emit() - - self.label.setStyleSheet(""" - QLabel { - border-radius: 8px; - background-color: transparent; - } - QLabel:hover { - background-color: rgba(255, 0, 0, 0.5); - } - """) - - layout = QtWidgets.QHBoxLayout() - layout.setContentsMargins(5, 0, 0, 0) - layout.addWidget(self.label) - self.setLayout(layout) - - -class MinimizeButton(QtWidgets.QWidget): - """Custom close button with hover effect.""" - clicked: QtCore.SignalInstance = QtCore.Signal() - - def __init__(self, parent=None): - super().__init__(parent) - - self.label = QtWidgets.QLabel("-", self) - - self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) - self.label.setAlignment(Qt.AlignCenter) - self.label.setFixedSize(16, 16) - self.label.mousePressEvent = lambda event: self.clicked.emit() - - self.setStyleSheet(""" - QLabel { - border-radius: 8px; - background-color: transparent; - } - QLabel:hover { - background-color: rgba(42, 157, 244, 0.5); - } - """) - - layout = QtWidgets.QHBoxLayout() - layout.setContentsMargins(5, 0, 0, 0) - layout.addWidget(self.label) - self.setLayout(layout) - - -def mousePressEvent(self, event): - if event.button() == Qt.LeftButton: - self.drag_start_pos = event.pos() - - -def mouseMoveEvent(self, event): - if not self.drag_start_pos: - return - - # Check if mouse moved beyond threshold - if (event.pos() - self.drag_start_pos).manhattanLength() > QtWidgets.QApplication.startDragDistance(): - index = self.tabAt(self.drag_start_pos) - if index >= 0: - startDrag(self, index) - -def startDrag(self, index): - """Start dragging a tab.""" - print("Dragging success") diff --git a/activity_browser/ui/widgets/tab_widget.py b/activity_browser/ui/widgets/tab_widget.py new file mode 100644 index 000000000..590d68fd2 --- /dev/null +++ b/activity_browser/ui/widgets/tab_widget.py @@ -0,0 +1,61 @@ +from qtpy import QtWidgets + +from .buttons import ABCloseButton, ABMinimizeButton + + +class ABTabWidget(QtWidgets.QTabWidget): + def __init__(self, name: str, *args): + """ + Initialize the GroupTabWidget. + + Args: + name (str): The name of the group, used as the object name for the widget. + *args: Additional positional arguments passed to the parent QTabWidget. + """ + super().__init__(*args) + self.setMovable(True) # Allow tabs to be rearranged. + self.setTabsClosable(True) # Allow tabs to be closed. + self.tabBar().setExpanding(False) + + + def resizeEvent(self, event): + super().resizeEvent(event) + # Force the tab bar to always fill the full width + self.tabBar().setMinimumWidth(self.width()) + + def addTab(self, widget, label, show_minimize=False): + """Override addTab to add custom buttons to each tab. + + Args: + widget: The widget to add as a tab + label: The label for the tab + show_minimize: If True, show minimize button; if False, show close button + """ + index = super().addTab(widget, label) + self._set_buttons(index, widget, show_minimize) + return index + + def insertTab(self, index, widget, label, show_minimize=False): + """Override insertTab to add custom buttons to each tab. + + Args: + index: The index at which to insert the tab + widget: The widget to add as a tab + label: The label for the tab + show_minimize: If True, show minimize button; if False, show close button + """ + index = super().insertTab(index, widget, label) + self._set_buttons(index, widget, show_minimize) + return index + + def _set_buttons(self, index, widget, show_minimize=False): + tab_bar = self.tabBar() + button = ABMinimizeButton() if show_minimize else ABCloseButton() + tab_bar.setTabButton(index, QtWidgets.QTabBar.ButtonPosition.RightSide, button) + button.clicked.connect(lambda w=widget: self.closeTabByWidget(w)) + + def closeTabByWidget(self, widget): + """Handle close button click using the widget reference.""" + index = self.indexOf(widget) + if index >= 0: + self.tabCloseRequested.emit(index) From c14cda6780003090934c3978f428bb98219eccdb Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 13 Nov 2025 14:11:16 +0100 Subject: [PATCH 118/267] Set tab position in settings --- activity_browser/app/main_window.py | 14 ++++++++- .../app/pages/settings/appearance.py | 30 ++++++++++++++++++- activity_browser/bwutils/settings.py | 1 + 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/activity_browser/app/main_window.py b/activity_browser/app/main_window.py index 6fe28e328..45fb30c93 100644 --- a/activity_browser/app/main_window.py +++ b/activity_browser/app/main_window.py @@ -217,7 +217,7 @@ def apply_settings(self, load=False): logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") app.actions.ProjectSwitch.set_warning_bar() - # Apply appearance settings + # Apply color scheme settings if app.settings["appearance"]["theme"] == "dark": hint = QtCore.Qt.ColorScheme.Dark elif app.settings["appearance"]["theme"] == "light": @@ -227,6 +227,18 @@ def apply_settings(self, load=False): app.application.styleHints().setColorScheme(hint) + # apply pane tab position + position = app.settings["appearance"]["pane_tab_position"] + if position == "top": + qt_position = QtWidgets.QTabWidget.North + if position == "bottom": + qt_position = QtWidgets.QTabWidget.South + if position == "left": + qt_position = QtWidgets.QTabWidget.West + if position == "right": + qt_position = QtWidgets.QTabWidget.East + self.setTabPosition(QtCore.Qt.DockWidgetArea.AllDockWidgetAreas, qt_position) + def connect_signals(self): app.signals.project.changed.connect(self.sync) app.signals.settings.changed.connect(self.apply_settings) diff --git a/activity_browser/app/pages/settings/appearance.py b/activity_browser/app/pages/settings/appearance.py index 4cc4b43c0..1ced98e4e 100644 --- a/activity_browser/app/pages/settings/appearance.py +++ b/activity_browser/app/pages/settings/appearance.py @@ -15,12 +15,22 @@ class AppearanceSettingsChapter(BaseSettingsChapter): "dark": "Dark theme", } + pane_tab_position_map = { + "top": "Top", + "bottom": "Bottom", + "left": "Left", + "right": "Right", + } + def __init__(self, parent=None): super().__init__(parent) # Theme selector self.theme_combo = QtWidgets.QComboBox() + # Pane tab position selector + self.pane_tab_position_combo = QtWidgets.QComboBox() + self.build_layout() self.connect_signals() self.reset() @@ -29,6 +39,7 @@ def connect_signals(self): """Connect signals and slots.""" # Emit changed signal when settings change self.theme_combo.currentTextChanged.connect(lambda: self.changed.emit()) + self.pane_tab_position_combo.currentTextChanged.connect(lambda: self.changed.emit()) def build_layout(self): """Build the chapter layout.""" @@ -41,7 +52,15 @@ def build_layout(self): theme_layout.addWidget(self.theme_combo, 0, 1) theme_group.setLayout(theme_layout) + # Pane tab position section + pane_tab_group = QtWidgets.QGroupBox("Pane Tab Position") + pane_tab_layout = QtWidgets.QGridLayout() + pane_tab_layout.addWidget(QtWidgets.QLabel("Position:"), 0, 0) + pane_tab_layout.addWidget(self.pane_tab_position_combo, 0, 1) + pane_tab_group.setLayout(pane_tab_layout) + layout.addWidget(theme_group) + layout.addWidget(pane_tab_group) layout.addStretch() self.setLayout(layout) @@ -52,19 +71,28 @@ def reset(self): self.theme_combo.clear() self.theme_combo.addItems(self.theme_map.values()) self.theme_combo.setCurrentText(self.theme_map.get(settings["appearance"]["theme"], "System default")) + + self.pane_tab_position_combo.clear() + self.pane_tab_position_combo.addItems(self.pane_tab_position_map.values()) + self.pane_tab_position_combo.setCurrentText(self.pane_tab_position_map.get(settings["appearance"]["pane_tab_position"], "Bottom")) def has_changes(self): """Check if there are unsaved changes.""" current_state = { 'theme': self.theme_combo.currentText(), + 'pane_tab_position': self.pane_tab_position_combo.currentText(), } initial_state = { 'theme': self.theme_map.get(settings["appearance"]["theme"], "System default"), + 'pane_tab_position': self.pane_tab_position_map.get(settings["appearance"]["pane_tab_position"], "Bottom"), } return current_state != initial_state def set_settings(self): - """Save startup settings.""" + """Save appearance settings.""" new_theme = self.theme_combo.currentText() settings["appearance"]["theme"] = [key for key, value in self.theme_map.items() if value == new_theme][0] + + new_pane_position = self.pane_tab_position_combo.currentText() + settings["appearance"]["pane_tab_position"] = [key for key, value in self.pane_tab_position_map.items() if value == new_pane_position][0] diff --git a/activity_browser/bwutils/settings.py b/activity_browser/bwutils/settings.py index eea79dda8..3051f1de9 100644 --- a/activity_browser/bwutils/settings.py +++ b/activity_browser/bwutils/settings.py @@ -16,6 +16,7 @@ }, "appearance": { "theme": "default", + "pane_tab_position": "bottom", } } From 033d49cfb9e5b5d3e23c4a7692b1edd4ca101971 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 17 Nov 2025 13:07:21 +0100 Subject: [PATCH 119/267] Reimplement uncertainty for the parameter table --- .../app/pages/parameters/parameters_new.py | 12 +++++++++--- activity_browser/bwutils/utils.py | 13 +++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/activity_browser/app/pages/parameters/parameters_new.py b/activity_browser/app/pages/parameters/parameters_new.py index 636b0c2e0..2c63f46eb 100644 --- a/activity_browser/app/pages/parameters/parameters_new.py +++ b/activity_browser/app/pages/parameters/parameters_new.py @@ -218,9 +218,9 @@ def _parameter_to_row(self, param, scope_label: str, database: str = None) -> di raise ValueError(f"Unknown parameter type: {type(param)}") row = { - "name": param.name, - "amount": data.get("amount"), - "uncertainty": data.get("uncertainty type"), + "name": parameter.name, + "amount": parameter.amount, + "uncertainty": parameter.uncertainty, "formula": data.get("formula"), "comment": data.get("comment"), "_parameter": parameter, @@ -376,6 +376,12 @@ def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole. parameter = refresh_parameter(parameter) app.actions.ParameterModify.run(parameter, column_name, value) + if column_name == "uncertainty": + parameter = refresh_parameter(parameter) + app.actions.ParameterUncertaintyModify.run(parameter.to_peewee_model(), uncertainty_dict=value) + + return True + return False def decorationData(self, index: QtCore.QModelIndex) -> any: diff --git a/activity_browser/bwutils/utils.py b/activity_browser/bwutils/utils.py index 61bf76791..3b082a81f 100644 --- a/activity_browser/bwutils/utils.py +++ b/activity_browser/bwutils/utils.py @@ -33,6 +33,19 @@ def deletable(self): except pw.DoesNotExist: return False + @property + def uncertainty(self): + uncertainty_keys = { + "uncertainty type", + "loc", + "scale", + "shape", + "minimum", + "maximum", + "negative", + } + return {k: v for k, v in self.data.items() if k in uncertainty_keys} + def as_gsa_tuple(self) -> tuple: """Return the parameter data formatted as follows: - Parameter name From d05e8f81a4353aef4343cc86cb5c6346d30b8fcb Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 17 Nov 2025 13:17:39 +0100 Subject: [PATCH 120/267] Reimplement uncertainty for the parameter tab in the activity details --- .../app/pages/activity_details/parameters_tab.py | 8 +++++++- activity_browser/ui/delegates/uncertainty.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/activity_browser/app/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py index 2dc2cc2b1..5dfe0b0a9 100644 --- a/activity_browser/app/pages/activity_details/parameters_tab.py +++ b/activity_browser/app/pages/activity_details/parameters_tab.py @@ -79,7 +79,7 @@ def build_df(self) -> pd.DataFrame: for name, param in data.items(): row = param._asdict() - row["uncertainty"] = param.data.get("uncertainty type") + row["uncertainty"] = param.uncertainty row["formula"] = param.data.get("formula") row["comment"] = param.data.get("comment") row["_parameter"] = param @@ -199,6 +199,12 @@ def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole. app.actions.ParameterModify.run(parameter, column_name, value) return True + if column_name == "uncertainty": + parameter = refresh_parameter(parameter) + app.actions.ParameterUncertaintyModify.run(parameter.to_peewee_model(), uncertainty_dict=value) + + return True + return False def decorationData(self, index: QtCore.QModelIndex) -> any: diff --git a/activity_browser/ui/delegates/uncertainty.py b/activity_browser/ui/delegates/uncertainty.py index 38130f2b0..cb782e719 100644 --- a/activity_browser/ui/delegates/uncertainty.py +++ b/activity_browser/ui/delegates/uncertainty.py @@ -37,7 +37,7 @@ def createEditor(self, parent, option, index): app.actions.CFUncertaintyModify.run( item["_impact_category_name"], [(item["_id"], item["_cf"]),] ) - else: + elif isinstance(index.data(), dict): return UncertaintyDialog(parent=app.main_window, initial=index.data()) def setEditorData(self, editor, index: QtCore.QModelIndex): From 0905ccaaaa83cec4cce34abc9f5ff0e3a7d1b815 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 17 Nov 2025 13:33:19 +0100 Subject: [PATCH 121/267] Reimplement uncertainty for the characterization factors --- .../actions/method/cf_uncertainty_modify.py | 29 +++++++-------- .../impact_category_details.py | 35 ++++++++++++++++++- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/activity_browser/app/actions/method/cf_uncertainty_modify.py b/activity_browser/app/actions/method/cf_uncertainty_modify.py index ac794fec5..2670650bd 100644 --- a/activity_browser/app/actions/method/cf_uncertainty_modify.py +++ b/activity_browser/app/actions/method/cf_uncertainty_modify.py @@ -19,28 +19,29 @@ class CFUncertaintyModify(ABAction): @classmethod @exception_dialogs - def run(cls, method_name: tuple, char_factors: List[tuple]): + def run(cls, method_name: tuple, char_factors: List[tuple], uncertainty_dict: dict = None): - initial = char_factors[0][1] - initial = initial if isinstance(initial, dict) else {} - - ok, uc_dict = UncertaintyDialog.get_uncertainty_dict( - parent=app.main_window, - initial=initial, - ) - - if not ok: - return + if uncertainty_dict is None: + initial = char_factors[0][1] + initial = initial if isinstance(initial, dict) else {} + + ok, uncertainty_dict = UncertaintyDialog.get_uncertainty_dict( + parent=app.main_window, + initial=initial, + ) + + if not ok: + return method = bd.Method(method_name) method_dict = {cf[0]: cf[1] for cf in method.load()} for cf in char_factors: if isinstance(cf[1], dict): - cf[1].update(uc_dict) + cf[1].update(uncertainty_dict) method_dict[cf[0]] = cf[1] else: - uc_dict["amount"] = cf[1] - method_dict[cf[0]] = uc_dict + uncertainty_dict["amount"] = cf[1] + method_dict[cf[0]] = uncertainty_dict method.write(list(method_dict.items())) diff --git a/activity_browser/app/pages/impact_category_details/impact_category_details.py b/activity_browser/app/pages/impact_category_details/impact_category_details.py index 4569c9aa5..1112d217d 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_details.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_details.py @@ -66,7 +66,7 @@ def build_layout(self): def build_df(self): df = pd.DataFrame(self.impact_category.load(), columns=["id", "data"]) df["amount"] = df["data"].apply(lambda x: x if isinstance(x, (float, int)) else x.get("amount")) - df["uncertainty"] = df["data"].apply(lambda x: 0 if isinstance(x, (float, int)) else x.get("uncertainty type")) + df["uncertainty"] = df["data"].apply(self.uncertainty_from_cf) other = app.metadata.dataframe[["id", "name", "categories", "database", "unit"]] @@ -77,6 +77,20 @@ def build_df(self): cols = ["name", "categories", "database", "amount", "unit", "uncertainty", "_id", "_impact_category_name", "_cf", "_editable"] return df[cols] + def uncertainty_from_cf(self, cf): + if isinstance(cf, dict): + uncertainty_keys = { + "uncertainty type", + "loc", + "scale", + "shape", + "minimum", + "maximum", + "negative", + } + return {k: v for k, v in cf.items() if k in uncertainty_keys} + return 0 + class CharacterizationFactorsView(widgets.ABNewTreeView): defaultColumnDelegates = { @@ -191,6 +205,19 @@ def __init__(self, page: ImpactCategoryDetailsPage): super().__init__(parent=page) self.page = page + def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: + """ + Sorts the model based on the given column and order. + + Args: + column (int): The column index to sort by. + order (Qt.SortOrder): The order to sort (ascending or descending). + """ + column_name = self.columns()[column] + if column_name == "uncertainty": + return + super().sort(column, order) + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: """ Sets the data for the given index. @@ -216,6 +243,12 @@ def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole. app.actions.CFAmountModify.run(row["_impact_category_name"], row["_id"], value) return True + if column_name == "uncertainty": + app.actions.CFUncertaintyModify.run( + row["_impact_category_name"], [(row["_id"], row["_cf"])], uncertainty_dict=value + ) + return True + return False def decorationData(self, index: QtCore.QModelIndex) -> any: From e6a2c10440731724452c87c85e0b5ae5ea277fd9 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 17 Nov 2025 13:52:33 +0100 Subject: [PATCH 122/267] New Tree View header fixes --- activity_browser/ui/core/tree_model.py | 2 +- activity_browser/ui/widgets/tree_view.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index c0c3c5392..d354e6481 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -273,7 +273,7 @@ def headerData(self, section: int, orientation: Qt.Orientation = Qt.Horizontal, return None if section == 0: - return "index" + return "" return self.df.columns[section - 1] diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py index 32e59bdc4..e367acdcf 100644 --- a/activity_browser/ui/widgets/tree_view.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -91,8 +91,8 @@ def __init__(self, parent=None): def setModel(self, model): super().setModel(model) - self.setColumnWidth(0, 30) - self.header().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) + self.setColumnWidth(0, 20) + self.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Fixed) model.modelAboutToBeReset.connect(self.clearColumnDelegates) model.modelReset.connect(self.setDefaultColumnDelegates) From fd0e0ef2de5bee4593096cb6658540e83f5fb656 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 17 Nov 2025 15:34:45 +0100 Subject: [PATCH 123/267] Apply parameter template button to scenario section --- activity_browser/app/actions/__init__.py | 1 + .../app/actions/save_parameters_to_excel.py | 39 +++++++++++++++++++ .../calculation_setup/scenario_section.py | 4 ++ 3 files changed, 44 insertions(+) create mode 100644 activity_browser/app/actions/save_parameters_to_excel.py diff --git a/activity_browser/app/actions/__init__.py b/activity_browser/app/actions/__init__.py index 236bdc483..f3d12acb7 100644 --- a/activity_browser/app/actions/__init__.py +++ b/activity_browser/app/actions/__init__.py @@ -95,3 +95,4 @@ from .migrations_install import MigrationsInstall from .pyside_upgrade import PysideUpgrade from .metadatastore_open import MetaDataStoreOpen +from .save_parameters_to_excel import SaveParametersToExcel diff --git a/activity_browser/app/actions/save_parameters_to_excel.py b/activity_browser/app/actions/save_parameters_to_excel.py new file mode 100644 index 000000000..50543d9cb --- /dev/null +++ b/activity_browser/app/actions/save_parameters_to_excel.py @@ -0,0 +1,39 @@ +import os + +import pandas as pd + +from qtpy import QtWidgets + +from activity_browser.app import application +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.utils import Parameters + + +class SaveParametersToExcel(ABAction): + """ + ABAction to export database(s) to Excel format (.xlsx). + """ + text = "Save parameters to Excel (.xlsx)" + tool_tip = "Save parameters to Excel format" + + @classmethod + @exception_dialogs + def run(cls, file_path: str = None): + if file_path is None: + suggestion = os.path.expanduser("~/parameters.xlsx") + + file_path, _ = QtWidgets.QFileDialog.getSaveFileName( + parent=application.main_window, + caption=f'Export parameters to Excel', + dir=suggestion, + filter='Excel spreadsheet (*.xlsx);; All files (*.*)' + ) + + if not file_path: + return + + data = [p[:3] for p in Parameters.from_bw_parameters()] + df = pd.DataFrame(data, columns=["Name", "Group", "default"]).set_index("Name") + df.to_excel(file_path) + + os.startfile(file_path) diff --git a/activity_browser/app/pages/calculation_setup/scenario_section.py b/activity_browser/app/pages/calculation_setup/scenario_section.py index a578868c7..ff7040fef 100644 --- a/activity_browser/app/pages/calculation_setup/scenario_section.py +++ b/activity_browser/app/pages/calculation_setup/scenario_section.py @@ -25,6 +25,9 @@ def __init__(self, parent=None): self._scenario_dataframe = pd.DataFrame() # set up the control buttons + self.get_template_btn = app.actions.SaveParametersToExcel.get_QButton() + self.get_template_btn.setText("Parameter template...") + self.table_btn = QtWidgets.QPushButton("Add scenarios...", self) self.save_scenario = QtWidgets.QPushButton("Save to file...", self) @@ -63,6 +66,7 @@ def __init__(self, parent=None): tool_row.addWidget(widgets.ABLabel.demiBold(" Scenarios:", self)) tool_row.addStretch() + tool_row.addWidget(self.get_template_btn) tool_row.addWidget(self.table_btn) tool_row.addWidget(self.save_scenario) tool_row.addWidget(self.group_box) From 4cd75d2657898f9495f0bdf776a6cd75b4c4e87c Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 18 Nov 2025 09:44:25 +0100 Subject: [PATCH 124/267] Column search fixes --- activity_browser/ui/core/tree_model.py | 35 ++++++++++++++++-------- activity_browser/ui/widgets/tree_view.py | 18 ++++++------ 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index d354e6481..d7b7b8c3a 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -1,11 +1,12 @@ from typing import Optional -from collections import defaultdict -from loguru import logger import pandas as pd -from PySide6.QtCore import QModelIndex, Qt + +from PySide6 import QtGui +from PySide6.QtCore import QModelIndex, Qt, QAbstractItemModel from PySide6.QtWidgets import QWidget -from PySide6.QtCore import QAbstractItemModel + +from activity_browser.ui.icons import qicons class TreeNode: @@ -54,6 +55,8 @@ def __init__(self, df: pd.DataFrame = None, parent: Optional[QWidget] = None, ch super().__init__(parent) self.df = df if df is not None else pd.DataFrame() self.df_query: dict[str, str] = {"model": "index == index"} # dictionary where queries can be registered + self.filtered_columns: set[int] = set() # set of column indices that have active filters, only used for the header icon + self.lazy = chunk_size > 0 self.chunk_size = chunk_size @@ -269,13 +272,22 @@ def isBranchNode(self, index: QModelIndex) -> bool: return not node.is_leaf def headerData(self, section: int, orientation: Qt.Orientation = Qt.Horizontal, role: int = Qt.DisplayRole): - if orientation == Qt.Vertical or not role == Qt.DisplayRole: + if orientation == Qt.Vertical: return None - - if section == 0: - return "" - - return self.df.columns[section - 1] + + if role == Qt.DisplayRole: + if section == 0: + return "" + + return self.df.columns[section - 1] + + if role == Qt.ItemDataRole.FontRole and section in self.filtered_columns: + font = QtGui.QFont() + font.setUnderline(True) + return font + + if role == Qt.ItemDataRole.DecorationRole and section in self.filtered_columns: + return qicons.filter def canFetchMore(self, parent: QModelIndex) -> bool: """Check if this parent has more children that can be loaded.""" @@ -333,7 +345,8 @@ def build_node_hierarchy(self, pandas_index: pd.Index) -> None: # Create a mapping from full path to DataFrame position path_to_position = {} - for df_pos, row_tuple in enumerate(idx_df.itertuples(index=False, name=None)): + for row_tuple in idx_df.itertuples(index=False, name=None): + df_pos = self.df.index.get_loc(row_tuple) path_to_position[row_tuple] = df_pos # Process each level to build the hierarchy diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py index e367acdcf..484633a66 100644 --- a/activity_browser/ui/widgets/tree_view.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -1,12 +1,10 @@ from loguru import logger -import pandas as pd - from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt from activity_browser.ui import delegates, core -from .item_model import ABItemModel + +from .line_edit import ABLineEdit @@ -26,11 +24,11 @@ def __init__(self, pos: QtCore.QPoint, view: "ABNewTreeView"): col_index = view.columnAt(pos.x()) col_name = model.columns()[col_index] - search_box = QtWidgets.QLineEdit(self) + search_box = ABLineEdit(self) search_box.setText(view.columnFilters.get(col_name, "")) search_box.setPlaceholderText("Search") search_box.selectAll() - search_box.textChanged.connect(lambda query: view.setColumnFilter(col_name, query)) + search_box.textChangedDebounce.connect(lambda query: view.setColumnFilter(col_name, query)) widget_action = QtWidgets.QWidgetAction(self) widget_action.setDefaultWidget(search_box) self.addAction(widget_action) @@ -103,7 +101,7 @@ def setModel(self, model): self.updateIndexColumnVisibility() self.updateBranchSpanning() - def model(self) -> ABItemModel: + def model(self) -> core.ABTreeModel: return super().model() # === Functionality related to contextmenus @@ -122,10 +120,10 @@ def setColumnFilter(self, column_name: str, query: str): if query: self.columnFilters[column_name] = query - # self.model().filtered_columns.add(col_index) + self.model().filtered_columns.add(col_index) elif column_name in self.columnFilters: del self.columnFilters[column_name] - # self.model().filtered_columns.discard(col_index) + self.model().filtered_columns.discard(col_index) self.applyFilter() @@ -144,7 +142,7 @@ def buildQuery(self) -> str: del self.columnFilters[col] for col, query in self.columnFilters.items(): - q = f"({col}.astype('str').str.contains('{self.format_query(query)}'))" + q = f"({col}.astype('str').str.contains('{self.format_query(query)}', False))" queries.append(q) # query for the all filter From fa627a5a92fe3166faff873d40b9a635d312f6cf Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 18 Nov 2025 10:21:38 +0100 Subject: [PATCH 125/267] Group parameter page by parameter type --- .../app/pages/parameters/parameters_new.py | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/activity_browser/app/pages/parameters/parameters_new.py b/activity_browser/app/pages/parameters/parameters_new.py index 2c63f46eb..876e58d92 100644 --- a/activity_browser/app/pages/parameters/parameters_new.py +++ b/activity_browser/app/pages/parameters/parameters_new.py @@ -102,7 +102,7 @@ def sync(self): df = self.build_df() df.reset_index(drop=True, inplace=True) self.model.set_dataframe(df) - self.model.group(["_scope"]) + self.model.group(["_param_type", "_scope"]) self.view.expandAll() exchanges_df = self.build_exchanges_df() @@ -126,12 +126,12 @@ def build_df(self) -> pd.DataFrame: # Project parameters for param in ProjectParameter.select(): - row = self._parameter_to_row(param, "Current project", None) + row = self._parameter_to_row(param) translated.append(row) # Database parameters for param in DatabaseParameter.select(): - row = self._parameter_to_row(param, f"Database: {param.database}", param.database) + row = self._parameter_to_row(param, "{param.database}", param.database) translated.append(row) # Activity parameters @@ -139,7 +139,7 @@ def build_df(self) -> pd.DataFrame: row = self._parameter_to_row(param, f"Group: {param.group}", param.database) translated.append(row) - columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_database", "_group"] + columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_database", "_group", "_param_type"] df = pd.DataFrame(translated, columns=columns) df["_is_new"] = False @@ -149,7 +149,6 @@ def build_df(self) -> pd.DataFrame: # Add for project new_rows.append({ "name": "New parameter...", - "_scope": "Current project", "_group": "project", "_param_type": "project", "_is_new": True, @@ -160,7 +159,7 @@ def build_df(self) -> pd.DataFrame: if not bd.databases[db_name].get("read_only", True): new_rows.append({ "name": "New parameter...", - "_scope": f"Database: {db_name}", + "_scope": f"{db_name}", "_database": db_name, "_group": db_name, "_param_type": "database", @@ -168,7 +167,7 @@ def build_df(self) -> pd.DataFrame: }) # Add for each activity group - activity_params = df[df._scope.str.startswith("Group: ", na=False)] + activity_params = df[df._scope.str.startswith("group: ", na=False)] groups = activity_params._group.unique() if len(activity_params) > 0 else [] for group_name in sorted(groups): group_data = activity_params[activity_params._group == group_name] @@ -176,7 +175,7 @@ def build_df(self) -> pd.DataFrame: if db_name and db_name in bd.databases and not bd.databases[db_name].get("read_only", True): new_rows.append({ "name": "New parameter...", - "_scope": f"Group: {group_name}", + "_scope": f"group: {group_name}", "_database": db_name, "_group": group_name, "_param_type": "activity", @@ -188,9 +187,9 @@ def build_df(self) -> pd.DataFrame: new_df = pd.DataFrame(new_rows) df = pd.concat([df, new_df], ignore_index=True) - return df + return df.sort_values(by="_param_type", key= lambda c: c.map({"project": 0, "database": 1, "activity": 2})) - def _parameter_to_row(self, param, scope_label: str, database: str = None) -> dict: + def _parameter_to_row(self, param, scope_label: str = None, database: str = None) -> dict: """ Converts a parameter to a row dictionary. @@ -208,12 +207,15 @@ def _parameter_to_row(self, param, scope_label: str, database: str = None) -> di if isinstance(param, ProjectParameter): parameter = Parameter(param.name, "project", data.get("amount"), data, "project") group = "project" + param_type = "project" elif isinstance(param, DatabaseParameter): parameter = Parameter(param.name, param.database, data.get("amount"), data, "database") group = param.database + param_type = "database" elif isinstance(param, ActivityParameter): parameter = Parameter(param.name, param.group, data.get("amount"), data, "activity") group = param.group + param_type = "activity" else: raise ValueError(f"Unknown parameter type: {type(param)}") @@ -223,6 +225,7 @@ def _parameter_to_row(self, param, scope_label: str, database: str = None) -> di "uncertainty": parameter.uncertainty, "formula": data.get("formula"), "comment": data.get("comment"), + "_param_type": param_type, "_parameter": parameter, "_scope": scope_label, "_database": database, From dc75fc67d6c06ce798410edf86541e3df2f0d1b4 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 18 Nov 2025 13:15:03 +0100 Subject: [PATCH 126/267] Move ABTreeView to be main solution --- .../pages/activity_details/consumers_tab.py | 2 +- .../app/pages/activity_details/data_tab.py | 2 +- .../pages/activity_details/exchanges_tab.py | 2 +- .../pages/activity_details/parameters_tab.py | 2 +- .../functional_unit_section.py | 2 +- .../impact_category_section.py | 2 +- .../impact_category_details.py | 2 +- activity_browser/app/pages/metadatastore.py | 12 +- .../app/pages/parameters/parameters_new.py | 4 +- .../app/pages/settings/project_manager.py | 4 +- activity_browser/app/panes/__init__.py | 1 - .../app/panes/calculation_setups.py | 2 +- .../app/panes/database_explorer.py | 182 ------------ .../app/panes/database_products.py | 2 +- activity_browser/app/panes/databases.py | 2 +- .../app/panes/impact_categories.py | 2 +- activity_browser/ui/widgets/__init__.py | 5 +- activity_browser/ui/widgets/item.py | 153 ---------- activity_browser/ui/widgets/item_model.py | 281 ------------------ activity_browser/ui/widgets/tree_view.py | 8 +- activity_browser/ui/widgets/treeview.py | 269 ----------------- 21 files changed, 22 insertions(+), 919 deletions(-) delete mode 100644 activity_browser/app/panes/database_explorer.py delete mode 100644 activity_browser/ui/widgets/item.py delete mode 100644 activity_browser/ui/widgets/item_model.py delete mode 100644 activity_browser/ui/widgets/treeview.py diff --git a/activity_browser/app/pages/activity_details/consumers_tab.py b/activity_browser/app/pages/activity_details/consumers_tab.py index 92e10b28d..203e022c1 100644 --- a/activity_browser/app/pages/activity_details/consumers_tab.py +++ b/activity_browser/app/pages/activity_details/consumers_tab.py @@ -97,7 +97,7 @@ def build_df(self, exchanges: list[bd.Edge]) -> pd.DataFrame: return df[cols] -class ConsumersView(widgets.ABNewTreeView): +class ConsumersView(widgets.ABTreeView): """ A view that displays the consumers in a tree structure. """ diff --git a/activity_browser/app/pages/activity_details/data_tab.py b/activity_browser/app/pages/activity_details/data_tab.py index 0920025a4..b276ccc54 100644 --- a/activity_browser/app/pages/activity_details/data_tab.py +++ b/activity_browser/app/pages/activity_details/data_tab.py @@ -90,7 +90,7 @@ def build_df(self) -> pd.DataFrame: return df[cols] -class DataView(widgets.ABNewTreeView): +class DataView(widgets.ABTreeView): """ A view that displays the data in a tree structure. diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index b7a788f64..8c57351c7 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -305,7 +305,7 @@ def setModelData(self, editor: QtWidgets.QComboBox, model, index): ) -class ExchangesView(widgets.ABNewTreeView): +class ExchangesView(widgets.ABTreeView): """ A view that displays the exchanges in a tree structure. diff --git a/activity_browser/app/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py index 5dfe0b0a9..8f991710c 100644 --- a/activity_browser/app/pages/activity_details/parameters_tab.py +++ b/activity_browser/app/pages/activity_details/parameters_tab.py @@ -100,7 +100,7 @@ def build_df(self) -> pd.DataFrame: return pd.DataFrame(translated, columns=columns) -class ParametersView(widgets.ABNewTreeView): +class ParametersView(widgets.ABTreeView): """ A view that displays the parameters in a tree structure. diff --git a/activity_browser/app/pages/calculation_setup/functional_unit_section.py b/activity_browser/app/pages/calculation_setup/functional_unit_section.py index d29026d3e..d688d4243 100644 --- a/activity_browser/app/pages/calculation_setup/functional_unit_section.py +++ b/activity_browser/app/pages/calculation_setup/functional_unit_section.py @@ -84,7 +84,7 @@ def build_df(self): return act_df[cols].reset_index(drop=True) -class FunctionalUnitView(widgets.ABNewTreeView): +class FunctionalUnitView(widgets.ABTreeView): defaultColumnDelegates = { "amount": delegates.AmountDelegate } diff --git a/activity_browser/app/pages/calculation_setup/impact_category_section.py b/activity_browser/app/pages/calculation_setup/impact_category_section.py index c9db43f66..a4fb1fee5 100644 --- a/activity_browser/app/pages/calculation_setup/impact_category_section.py +++ b/activity_browser/app/pages/calculation_setup/impact_category_section.py @@ -48,7 +48,7 @@ def build_df(self): return df[cols] -class ImpactCategoryView(widgets.ABNewTreeView): +class ImpactCategoryView(widgets.ABTreeView): defaultColumnDelegates = { "name": delegates.StringDelegate } diff --git a/activity_browser/app/pages/impact_category_details/impact_category_details.py b/activity_browser/app/pages/impact_category_details/impact_category_details.py index 1112d217d..1cf98eccf 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_details.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_details.py @@ -92,7 +92,7 @@ def uncertainty_from_cf(self, cf): return 0 -class CharacterizationFactorsView(widgets.ABNewTreeView): +class CharacterizationFactorsView(widgets.ABTreeView): defaultColumnDelegates = { "amount": delegates.FloatDelegate, "categories": delegates.ListDelegate, diff --git a/activity_browser/app/pages/metadatastore.py b/activity_browser/app/pages/metadatastore.py index 11f5ddf19..a64a9b602 100644 --- a/activity_browser/app/pages/metadatastore.py +++ b/activity_browser/app/pages/metadatastore.py @@ -1,6 +1,6 @@ from qtpy import QtWidgets -from activity_browser.ui import widgets, delegates +from activity_browser.ui import widgets, delegates, core from activity_browser.app import metadata, signals @@ -9,7 +9,7 @@ def __init__(self, parent=None): super().__init__(parent) self.setObjectName("MetaDataStorePage") - self.model = MDSModel(self, metadata.dataframe) + self.model = core.ABTreeModel(metadata.dataframe, self) self.view = MDSView(self) self.view.setModel(self.model) @@ -20,7 +20,7 @@ def connect_signals(self): signals.metadata.synced.connect(self.sync) def sync(self): - self.model.setDataFrame(metadata.dataframe) + self.model.set_dataframe(metadata.dataframe) def build_layout(self): layout = QtWidgets.QVBoxLayout() @@ -32,9 +32,3 @@ class MDSView(widgets.ABTreeView): def __init__(self, parent=None): super().__init__(parent) self.setItemDelegate(delegates.StringDelegate(self)) - -class MDSItem(widgets.ABDataItem): - pass - -class MDSModel(widgets.ABItemModel): - pass diff --git a/activity_browser/app/pages/parameters/parameters_new.py b/activity_browser/app/pages/parameters/parameters_new.py index 876e58d92..c0b11673a 100644 --- a/activity_browser/app/pages/parameters/parameters_new.py +++ b/activity_browser/app/pages/parameters/parameters_new.py @@ -278,7 +278,7 @@ def build_exchanges_df(self) -> pd.DataFrame: return pd.DataFrame(translated, columns=columns) -class ProjectParametersView(widgets.ABNewTreeView): +class ProjectParametersView(widgets.ABTreeView): """ A view that displays the project parameters in a tree structure. @@ -470,7 +470,7 @@ def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: return parameters_in_scope(parameter=parameter) -class ParameterizedExchangesView(widgets.ABNewTreeView): +class ParameterizedExchangesView(widgets.ABTreeView): """ A view that displays parameterized exchanges in a tree structure. diff --git a/activity_browser/app/pages/settings/project_manager.py b/activity_browser/app/pages/settings/project_manager.py index 7856f6ee0..6f49e7883 100644 --- a/activity_browser/app/pages/settings/project_manager.py +++ b/activity_browser/app/pages/settings/project_manager.py @@ -117,7 +117,7 @@ def build_template_df(self) -> pd.DataFrame: return pd.DataFrame(data, columns=cols) -class ProjectView(widgets.ABNewTreeView): +class ProjectView(widgets.ABTreeView): class ContextMenu(widgets.ABMenu): menuSetup = [ @@ -202,7 +202,7 @@ def decorationData(self, index): return None -class TemplateView(widgets.ABNewTreeView): +class TemplateView(widgets.ABTreeView): class ContextMenu(widgets.ABMenu): menuSetup = [] diff --git a/activity_browser/app/panes/__init__.py b/activity_browser/app/panes/__init__.py index a1eaf0974..e6b2aab5a 100644 --- a/activity_browser/app/panes/__init__.py +++ b/activity_browser/app/panes/__init__.py @@ -1,4 +1,3 @@ -from .database_explorer import DatabaseExplorerPane from .database_products import DatabaseProductsPane from .databases import DatabasesPane from .impact_categories import ImpactCategoriesPane diff --git a/activity_browser/app/panes/calculation_setups.py b/activity_browser/app/panes/calculation_setups.py index f4b9fe8b3..b7a221ada 100644 --- a/activity_browser/app/panes/calculation_setups.py +++ b/activity_browser/app/panes/calculation_setups.py @@ -79,7 +79,7 @@ def build_df(self) -> pd.DataFrame: return pd.DataFrame(data, columns=cols) -class CalculationSetupsView(widgets.ABNewTreeView): +class CalculationSetupsView(widgets.ABTreeView): """ A view that displays the calculation setups in a tree structure. diff --git a/activity_browser/app/panes/database_explorer.py b/activity_browser/app/panes/database_explorer.py deleted file mode 100644 index ea7e3ae3d..000000000 --- a/activity_browser/app/panes/database_explorer.py +++ /dev/null @@ -1,182 +0,0 @@ -from loguru import logger - -import pandas as pd -from qtpy import QtWidgets, QtCore, QtGui - -import bw2data as bd - -from activity_browser.ui import widgets -from activity_browser.app import application, signals, metadata - - - -COLUMNS = ["name", "type", "exchanges", "database", "code"] -DETAILS_COLUMNS = ["input", "output", "type", "amount"] - - -DEFAULT_STATE = { - "columns": ["Activity", "Product", "Type", "Unit", "Location"], - "visible_columns": ["Activity", "Product", "Type", "Unit", "Location"], -} - - -NODETYPES = { - "all_nodes": [], - "processes": ["process", "multifunctional", "processwithreferenceproduct", "nonfunctional"], - "products": ["product", "processwithreferenceproduct", "waste"], - "biosphere": ["natural resource", "emission", "inventory indicator", "economic", "social"], -} - - -class DatabaseExplorerPane(widgets.ABAbstractPane): - - def __init__(self, db_name: str, parent=None): - super().__init__(parent, QtCore.Qt.WindowType.Window) - self.title = "Database Explorer - " + db_name - self.database = bd.Database(db_name) - self.model = NodeModel(self) - - # Create the QTableView and set the model - self.table_view = NodeView(self) - self.table_view.setModel(self.model) - self.model.setDataFrame(self.build_df()) - - self.search = QtWidgets.QLineEdit(self) - self.search.setMaximumHeight(30) - self.search.setPlaceholderText("Quick Search") - - self.search.textChanged.connect(self.table_view.setAllFilter) - - self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical, self) - self.splitter.setChildrenCollapsible(False) - self.splitter.addWidget(self.table_view) - - self.setLayout(QtWidgets.QVBoxLayout()) - self.layout().addWidget(self.search) - self.layout().addWidget(self.splitter) - - # connect signals - signals.database.deleted.connect(self.deleteLater) - signals.project.changed.connect(self.deleteLater) - signals.metadata.synced.connect(self.sync) - self.table_view.filtered.connect(self.search_error) - - def sync(self): - self.model.setDataFrame(self.build_df()) - - def build_df(self) -> pd.DataFrame: - import sqlite3 - from bw2data.backends import sqlite3_lci_db - - full_df = metadata.get_database_metadata(self.database.name) - - con = sqlite3.connect(sqlite3_lci_db._filepath) - sql = f"SELECT output_code FROM exchangedataset WHERE output_database == '{self.database.name}'" - excs = pd.read_sql(sql, con) - con.close() - - count = excs.groupby(excs.columns.tolist()).size() - count.name = "exchanges" - full_df = full_df.join(count, "code") - - return full_df - - def search_error(self, reset=False): - if reset: - self.search.setPalette(application.palette()) - return - - palette = self.search.palette() - palette.setColor(QtGui.QPalette.ColorRole.Base, QtGui.QColor(255, 128, 128)) - self.search.setPalette(palette) - - -class NodeView(widgets.ABTreeView): - - def __init__(self, above: QtWidgets.QWidget=None, parent=None): - super().__init__(parent) - self.setSortingEnabled(True) - self.setDragEnabled(True) - self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DragOnly) - self.setSelectionBehavior(widgets.ABTreeView.SelectionBehavior.SelectItems) - self.setSelectionMode(widgets.ABTreeView.SelectionMode.ExtendedSelection) - - self.above = above - self.below: QtWidgets.QWidget = QtWidgets.QWidget(self) - - def deleteLater(self): - super().deleteLater() - self.below.deleteLater() - - def mouseReleaseEvent(self, event): - self.below.deleteLater() - self.below = QtWidgets.QWidget(self) - - if not self.selectedIndexes(): - return - - idx = self.selectedIndexes()[0] - col_name = self.model().columns()[idx.column()] - item = idx.internalPointer() - data = item[col_name] - - if col_name == "exchanges": - act = bd.get_node(database=item["database"], code=item["code"]) - model = NodeModel() - model.setDataFrame(pd.DataFrame(act.exchanges())) - - self.below = NodeView(self) - self.below.setModel(model) - - self.parent().addWidget(self.below) - - elif isinstance(data, (dict, list, tuple)): - if isinstance(data, dict): - df = pd.DataFrame.from_dict(data, orient="index") - df.reset_index(inplace=True) - else: - df = pd.DataFrame(data) - model = NodeModel(dataframe=df) - - self.below = NodeView(self) - self.below.setModel(model) - - self.parent().addWidget(self.below) - - elif isinstance(data, (str, float, int)): - - if isinstance(data, float) and pd.isna(data): - return - - self.below = QtWidgets.QPlainTextEdit(str(data), self) - self.parent().addWidget(self.below) - - -class NodeItem(widgets.ABDataItem): - - def displayData(self, col: int, key: str): - data = self[key] - - if data is None: - return None - - if isinstance(data, (str, float, int)): - if key == "exchanges": - return f"Exchanges: {data}" if not pd.isna(data) else "Exchanges: 0" - - - rep = str(data).replace("\n", " ") - if len(rep) > 200: - return rep[:200] + "..." - return rep - - elif hasattr(data, "__len__"): - return f"{type(data).__name__.capitalize()}: {len(data)}" - - else: - return str(type(data)) - - -class NodeModel(widgets.ABItemModel): - dataItemClass = NodeItem - diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 547294a7f..41fba283a 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -203,7 +203,7 @@ def search_error(self, reset=False): self.search.setPalette(palette) -class ProductView(ui.widgets.ABNewTreeView): +class ProductView(ui.widgets.ABTreeView): """ A view that displays the products in a tree structure. diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py index 1dd29e450..1c8f6d575 100644 --- a/activity_browser/app/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -100,7 +100,7 @@ def build_df(self) -> pd.DataFrame: return pd.DataFrame(data, columns=cols) -class DatabasesView(widgets.ABNewTreeView): +class DatabasesView(widgets.ABTreeView): """ A view that displays the databases in a tree structure. diff --git a/activity_browser/app/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py index 5089f0721..e2436591e 100644 --- a/activity_browser/app/panes/impact_categories.py +++ b/activity_browser/app/panes/impact_categories.py @@ -74,7 +74,7 @@ def build_df(self): return df[cols] -class ImpactCategoriesView(widgets.ABNewTreeView): +class ImpactCategoriesView(widgets.ABTreeView): defaultColumnDelegates = { "groups": delegates.ListDelegate, } diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index 7177df41a..d4256af60 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -4,9 +4,6 @@ from .cutoff_menu import CutoffMenu from .line_edit import (ABLineEdit, SignalledComboEdit, SignalledLineEdit, SignalledPlainTextEdit) -from .treeview import ABTreeView -from .item_model import ABItemModel -from .item import ABAbstractItem, ABBranchItem, ABDataItem from .line import ABHLine, ABVLine from .formula_edit import ABFormulaEdit @@ -21,6 +18,6 @@ from .central import CentralTabWidget from .menu import ABMenu from .drop_overlay import ABDropOverlay -from .tree_view import ABNewTreeView +from .tree_view import ABTreeView from .buttons import ABCloseButton, ABMinimizeButton from .tab_widget import ABTabWidget diff --git a/activity_browser/ui/widgets/item.py b/activity_browser/ui/widgets/item.py deleted file mode 100644 index 4ac9c0b66..000000000 --- a/activity_browser/ui/widgets/item.py +++ /dev/null @@ -1,153 +0,0 @@ -import pandas as pd -from qtpy import QtGui, QtCore - - -class ABAbstractItem: - - def __init__(self, key, parent=None): - self._key = key - self._child_keys = [] - self._child_items = {} - self._parent = None - - if parent: - self.set_parent(parent) - - def __getitem__(self, item): - raise NotImplementedError - - def parent(self) -> "ABAbstractItem": - return self._parent - - def key(self): - return self._key - - def children(self): - return self._child_items - - def path(self) -> [str]: - return self.parent().path() + [self.key()] if self.parent() else [] - - def rank(self) -> int: - """Return the rank of the ABItem within the parent. Returns -1 if there is no parent.""" - if self.parent is None: - return -1 - return self.parent()._child_keys.index(self.key()) - - def has_children(self) -> bool: - return bool(self._child_keys) - - def set_parent(self, parent: "ABAbstractItem"): - if self.key() in parent.children(): - raise KeyError(f"Item {self.key()} is already a child of {parent.key()}") - - if self.parent(): - self.parent()._child_keys.remove(self.key()) - del self.parent()._child_items[self.key()] - - parent._child_items[self.key()] = self - parent._child_keys.append(self.key()) - self._parent = parent - - def loc(self, key_or_path: object | list[object], default=None): - key = key_or_path.pop(0) if isinstance(key_or_path, list) else key_or_path - - if isinstance(key_or_path, list) and len(key_or_path) > 0: - return self._child_items[key].loc(key_or_path, default) - - return self._child_items.get(key, default) - - def iloc(self, index: int, default=None): - return self.loc(self._child_keys[index], default) - - def displayData(self, col: int, key: str): - return None - - def decorationData(self, col: int, key: str): - return None - - def fontData(self, col: int, key: str): - return None - - def backgroundData(self, col: int, key: str): - return None - - def foregroundData(self, col: int, key: str): - return None - - def flags(self, col: int, key: str): - return QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable - - def setData(self, col: int, key: str, value): - return False - - -class ABBranchItem(ABAbstractItem): - - def __getitem__(self, item): - return None - - def put(self, item: ABAbstractItem, path): - key = path.pop(0) - if path: - sub = self.loc(key) - sub = sub if sub else self.__class__(key, self) - sub.put(item, path) - else: - item.set_parent(self) - - def set_parent(self, parent: "ABAbstractItem"): - if self.key() in parent._child_items: - twin = parent.loc(self.key()) - for child in twin.child_items.values(): - child.set_parent(self) - - if self.parent(): - self.parent()._child_keys.remove(self.key()) - del self.parent()._child_items[self.key()] - - parent._child_items[self.key()] = self - - branches = [isinstance(parent._child_items[key], ABBranchItem) for key in parent._child_keys] - i = branches.index(False) if False in branches else len(branches) - parent._child_keys.insert(i, self.key()) - self._parent = parent - - def displayData(self, col: int, key: str): - if col == 0: - return self.key() - else: - return None - - -class ABDataItem(ABAbstractItem): - def __init__(self, key, data, parent=None): - super().__init__(key, parent) - self.data = data - - def __getitem__(self, item): - return self.data.get(item) - - def displayData(self, col: int, key: str): - data = self[key] - - if isinstance(data, (list, tuple)): - # skip isna check for lists/tuples - pass - elif data is None or pd.isna(data): - return None - - if isinstance(data, str): - # clean up the data to a table-readable format - data = data.replace("\n", " ") - - return data - - def fontData(self, col: int, key: str): - font = QtGui.QFont() - - # set the font to italic if the display value is Undefined - if self.displayData(col, key) == "Undefined": - font.setItalic(True) - - return font diff --git a/activity_browser/ui/widgets/item_model.py b/activity_browser/ui/widgets/item_model.py deleted file mode 100644 index 0ebcccbff..000000000 --- a/activity_browser/ui/widgets/item_model.py +++ /dev/null @@ -1,281 +0,0 @@ -import pandas as pd -from qtpy import QtCore, QtGui -from qtpy.QtCore import Qt, Signal, SignalInstance - -from activity_browser.ui.icons import qicons - -from .item import ABAbstractItem, ABBranchItem, ABDataItem - - -class ABItemModel(QtCore.QAbstractItemModel): - grouped: SignalInstance = Signal(list) - - dataItemClass = ABDataItem - branchItemClass = ABBranchItem - - def __init__(self, parent=None, dataframe=None): - super().__init__(parent) - - if dataframe is None: - dataframe = pd.DataFrame() - - self.dataframe: pd.DataFrame = dataframe # DataFrame containing the visible data - self.root: ABBranchItem = self.branchItemClass("root") # root ABItem for the object tree - self.grouped_columns: list[int] = list() # list of columns that are currently being grouped - self.filtered_columns: set[int] = set() # set of all columns that have filters applied - self.sort_column: int = -1 # column that is currently sorted - self.sort_order: Qt.SortOrder = Qt.SortOrder.AscendingOrder - self._query = "" # Pandas query currently applied to the dataframe - - self.setDataFrame(self.dataframe) - - def columns(self): - return [col for col in self.dataframe.columns if not str(col).startswith("_")] - - def headers(self): - return self.columns() - - def index(self, row: int, column: int, parent: QtCore.QModelIndex = ...) -> QtCore.QModelIndex: - """ - Create a QModelIndex based on a specific row, column and parent. Sets the associated ABItem as - internalPointer. This will be the root ABItem if the parent is invalid. - """ - # get the parent ABItem, or the root ABItem if the parent is invalid - parent = parent.internalPointer() if parent.isValid() else self.root - - # get the child ABItem from the parent with the same rank as the specified row - child = parent.iloc(row) - - # create and return a QModelIndex - return self.createIndex(row, column, child) - - def indexFromPath(self, path: [str]) -> QtCore.QModelIndex: - """ - Create a QModelIndex based on a specific path for the ABItem tree. The index column will be 0. - """ - # get the ABItem for that specific path - child = self.root.loc(path) - if child is None: - return QtCore.QModelIndex() - - # create and return a QModelIndex with the child's rank as row and 0 as column - return self.createIndex(child.rank(), 0, child) - - def parent(self, child: QtCore.QModelIndex) -> QtCore.QModelIndex: - """ - Return the parent of a QModelIndex. - """ - if not child.isValid(): - return QtCore.QModelIndex() - - # get the ABItem from the QModelIndex - child = child.internalPointer() - - # try to get the parent ABItem from the child - try: - parent = child.parent() - # return an invalid/empty QModelIndex if this fails - except: - return QtCore.QModelIndex() - - # if the parent is the root ABItem return an invalid/empty QModelIndex - if parent == self.root: - return QtCore.QModelIndex() - - # create and return a QModelIndex with the child's rank as row and 0 as column - return self.createIndex(parent.rank(), 0, parent) - - def rowCount(self, parent: QtCore.QModelIndex = ...) -> int: - """ - Return the number of rows within the model - """ - # return 0 if there is no DataFrame - if self.dataframe is None: - return 0 - # if the parent is the top of the table, the rowCount is the number of children for the root ABItem - if not parent.isValid(): - value = len(self.root.children()) - # else it's the number of children within the ABItem saved within the internalPointer - elif isinstance(parent.internalPointer(), ABAbstractItem): - value = len(parent.internalPointer().children()) - # this shouldn't happen, but a failsafe - else: - value = 0 - return value - - def columnCount(self, parent: QtCore.QModelIndex = ...) -> int: - """ - Return the number of columns within the model - """ - # return 0 if there is no DataFrame - if self.dataframe is None: - return 0 - return len(self.columns()) - - def data(self, index: QtCore.QModelIndex, role=Qt.ItemDataRole.DisplayRole): - """ - Get the data associated with a specific index and role - """ - if not index.isValid() or not isinstance(index.internalPointer(), ABAbstractItem): - return None - - item: ABAbstractItem = index.internalPointer() - col = index.column() - key = self.columns()[col] - - # redirect to the item's displayData method - if role == Qt.ItemDataRole.DisplayRole: - return item.displayData(col, key) - - # redirect to the item's fontData method - if role == Qt.ItemDataRole.FontRole: - return item.fontData(col, key) - - # redirect to the item's decorationData method - if role == Qt.ItemDataRole.DecorationRole: - return item.decorationData(col, key) - - if role == Qt.ItemDataRole.BackgroundRole: - return item.backgroundData(col, key) - - if role == Qt.ItemDataRole.ForegroundRole: - return item.foregroundData(col, key) - - # else return None - return None - - def setData(self, index: QtCore.QModelIndex, value, role=Qt.ItemDataRole.EditRole) -> bool: - if not index.isValid() or not isinstance(index.internalPointer(), ABAbstractItem): - return False - - if role == Qt.ItemDataRole.EditRole: - success = index.internalPointer().setData(index.column(), self.columns()[index.column()], value) - - if success: - self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole]) - return success - - return False - - def headerData(self, section, orientation=Qt.Orientation.Horizontal, role=Qt.ItemDataRole.DisplayRole): - if orientation != Qt.Orientation.Horizontal: - return None - - if role == Qt.ItemDataRole.DisplayRole: - if section == 0 and self.grouped_columns: - return " > ".join([self.headers()[column] for column in self.grouped_columns] + [self.headers()[0]]) - return self.headers()[section] - - if role == Qt.ItemDataRole.FontRole and section in self.filtered_columns: - font = QtGui.QFont() - font.setUnderline(True) - return font - - if role == Qt.ItemDataRole.DecorationRole and section in self.filtered_columns: - return qicons.filter - - def flags(self, index): - if not index.isValid() or not isinstance(index.internalPointer(), ABAbstractItem): - return Qt.ItemFlag.NoItemFlags - if index.column() > len(self.columns()) - 1: - return Qt.ItemFlag.NoItemFlags - - return index.internalPointer().flags(index.column(), self.columns()[index.column()]) - - def endResetModel(self): - """ - Reset the model based on dataframe, query and grouped columns. Should be called to reflect the changes of - changing the dataframe, grouped columns or query string. - """ - # if self.dataframe is None or self.dataframe.empty: - # return - - # apply any queries to the dataframe - if q := self.query(): - df = self.dataframe.query(q).reset_index(drop=True).copy() - else: - df = self.dataframe.copy() - - if not self.sort_column > len(self.columns()) - 1 and self.sort_column != -1: - # apply the sorting - df.sort_values( - by=self.columns()[self.sort_column], - ascending=(self.sort_order == Qt.SortOrder.AscendingOrder), - inplace=True, ignore_index=True - ) - - # rebuild the ABItem tree - self.root = self.branchItemClass("root") - items = self.createItems(df) - - # if no grouping of Entries, just append everything as a direct child of the root ABItem - if not self.grouped_columns: - for i, item in enumerate(items): - item.set_parent(self.root) - # else build paths based on the grouped columns and create an ABItem tree - else: - column_names = [self.columns()[column] for column in self.grouped_columns] - - for i, *paths in df[column_names].itertuples(): - joined_path = [] - - for path in paths: - joined_path.extend(path) if isinstance(path, (list, tuple)) else joined_path.append(path) - - joined_path.append(i) - self.root.put(items[df.index.get_loc(i)], joined_path) - - super().endResetModel() - - def createItems(self, dataframe=None) -> list["ABAbstractItem"]: - if dataframe is None: - dataframe = self.dataframe - return [self.dataItemClass(index, data) for index, data in dataframe.to_dict(orient="index").items()] - - def setDataFrame(self, dataframe: pd.DataFrame): - self.beginResetModel() - self.dataframe = dataframe - self.endResetModel() - - def sort(self, column: int, order=Qt.SortOrder.AscendingOrder): - if column + 1 > len(self.columns()): - return - if column == self.sort_column and order == self.sort_order: - return - - self.beginResetModel() - - self.sort_column = column - self.sort_order = order - - self.endResetModel() - - def group(self, column: int): - self.beginResetModel() - self.grouped_columns.append(column) - self.endResetModel() - self.grouped.emit(self.grouped_columns) - - def ungroup(self): - self.beginResetModel() - self.grouped_columns.clear() - self.endResetModel() - self.grouped.emit(self.grouped_columns) - - def query(self) -> str: - return self._query - - def setQuery(self, query: str): - """Apply the query string to the dataframe and rebuild the model""" - self.beginResetModel() - self._query = query - self.endResetModel() - - def hasChildren(self, parent: QtCore.QModelIndex): - item = parent.internalPointer() - if isinstance(item, ABAbstractItem): - return item.has_children() - return super().hasChildren(parent) - - - diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py index 484633a66..13d346f67 100644 --- a/activity_browser/ui/widgets/tree_view.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -7,16 +7,14 @@ from .line_edit import ABLineEdit - - -class ABNewTreeView(QtWidgets.QTreeView): +class ABTreeView(QtWidgets.QTreeView): # fired when the filter is applied, fires False when an exception happens during querying filtered: QtCore.SignalInstance = QtCore.Signal(bool) defaultColumnDelegates = {} class HeaderMenu(QtWidgets.QMenu): - def __init__(self, pos: QtCore.QPoint, view: "ABNewTreeView"): + def __init__(self, pos: QtCore.QPoint, view: "ABTreeView"): super().__init__(view) model = view.model() @@ -168,7 +166,7 @@ def buildQuery(self) -> str: def applyFilter(self): query = self.buildQuery() try: - self.model().filter("ABNewTreeView", query) + self.model().filter("ABTreeView", query) self.filtered.emit(True) except Exception as e: logger.info(f"{self.__class__.__name__} {type(e).__name__} in query: {e}") diff --git a/activity_browser/ui/widgets/treeview.py b/activity_browser/ui/widgets/treeview.py deleted file mode 100644 index f6c389514..000000000 --- a/activity_browser/ui/widgets/treeview.py +++ /dev/null @@ -1,269 +0,0 @@ -from loguru import logger - -import pandas as pd - -from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt - -from .item_model import ABItemModel - - - - -class ABTreeView(QtWidgets.QTreeView): - # fired when the filter is applied, fires False when an exception happens during querying - filtered: QtCore.SignalInstance = QtCore.Signal(bool) - - defaultColumnDelegates = {} - - class HeaderMenu(QtWidgets.QMenu): - def __init__(self, pos: QtCore.QPoint, view: "ABTreeView"): - super().__init__(view) - - model = view.model() - - col_index = view.columnAt(pos.x()) - col_name = model.columns()[col_index] - - search_box = QtWidgets.QLineEdit(self) - search_box.setText(view.columnFilters.get(col_name, "")) - search_box.setPlaceholderText("Search") - search_box.selectAll() - search_box.textChanged.connect(lambda query: view.setColumnFilter(col_name, query)) - widget_action = QtWidgets.QWidgetAction(self) - widget_action.setDefaultWidget(search_box) - self.addAction(widget_action) - - self.addAction(QtGui.QIcon(), "Group by column", lambda: model.group(col_index)) - self.addAction(QtGui.QIcon(), "Ungroup", model.ungroup) - self.addAction(QtGui.QIcon(), "Clear column filter", lambda: view.setColumnFilter(col_name, "")) - self.addAction(QtGui.QIcon(), "Clear all filters", - lambda: [view.setColumnFilter(name, "") for name in list(view.columnFilters.keys())], - ) - self.addSeparator() - - def toggle_slot(action: QtWidgets.QAction): - index = action.data() - hidden = view.isColumnHidden(index) - view.setColumnHidden(index, not hidden) - - view_menu = QtWidgets.QMenu(view) - view_menu.setTitle("View") - self.view_actions = [] - - for i in range(1, len(model.columns())): - action = QtWidgets.QAction(model.columns()[i]) - action.setCheckable(True) - action.setChecked(not view.isColumnHidden(i)) - action.setData(i) - view_menu.addAction(action) - self.view_actions.append(action) - - view_menu.triggered.connect(toggle_slot) - - self.addMenu(view_menu) - - search_box.setFocus() - - class ContextMenu(QtWidgets.QMenu): - def __init__(self, pos, view): - super().__init__(view) - - def __init__(self, parent=None): - super().__init__(parent) - - self.setUniformRowHeights(True) - - self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - self.customContextMenuRequested.connect(self.showContextMenu) - - self.setSelectionBehavior(QtWidgets.QTreeView.SelectionBehavior.SelectRows) - self.setSelectionMode(QtWidgets.QTreeView.SelectionMode.ExtendedSelection) - - header = self.header() - header.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - header.customContextMenuRequested.connect(self.showHeaderMenu) - - self.expanded_paths = set() - self.expanded.connect(lambda index: self.expanded_paths.add(tuple(index.internalPointer().path()))) - self.collapsed.connect(lambda index: self.expanded_paths.discard(tuple(index.internalPointer().path()))) - - self.columnFilters: dict[str, str] = {} # dict[column_name, query] for filtering the dataframe - self.allFilter: str = "" # filter applied to the entire dataframe - - def setModel(self, model): - if not isinstance(model, ABItemModel): - raise TypeError("Model must be an instance of ABItemModel") - super().setModel(model) - - model.modelReset.connect(self.expand_after_reset) - model.modelAboutToBeReset.connect(self.clearColumnDelegates) - model.modelReset.connect(self.setDefaultColumnDelegates) - - self.setDefaultColumnDelegates() - - def model(self) -> ABItemModel: - return super().model() - - # === Functionality related to contextmenus - - def showContextMenu(self, pos): - self.ContextMenu(pos, self).exec_(self.mapToGlobal(pos)) - - def showHeaderMenu(self, pos): - self.HeaderMenu(pos, self).exec_(self.mapToGlobal(pos)) - - def setColumnFilter(self, column_name: str, query: str): - """ - Set a filter for a specific column using a string query. If the query is empty remove the filter from the column - """ - col_index = self.model().columns().index(column_name) - - if query: - self.columnFilters[column_name] = query - self.model().filtered_columns.add(col_index) - elif column_name in self.columnFilters: - del self.columnFilters[column_name] - self.model().filtered_columns.discard(col_index) - - self.applyFilter() - - # === Functionality related to filtering - - def setAllFilter(self, query: str): - self.allFilter = query - self.applyFilter() - - def buildQuery(self) -> str: - queries = ["(index == index)"] - - # query for the column filters - for col in list(self.columnFilters): - if col not in self.model().columns(): - del self.columnFilters[col] - - for col, query in self.columnFilters.items(): - q = f"({col}.astype('str').str.contains('{self.format_query(query)}'))" - queries.append(q) - - # query for the all filter - if self.allFilter.startswith('='): - queries.append(f"({self.allFilter[1:]})") - else: - all_queries = [] - formatted_filter = self.format_query(self.allFilter) - - for i, col in enumerate(self.model().columns()): - if self.isColumnHidden(i) and i not in self.model().grouped_columns: - continue - all_queries.append(f"(`{col}`.astype('str').str.contains('{formatted_filter}', False))") - - q = f"({' | '.join(all_queries)})" - queries.append(q) - - query = " & ".join(queries) - logger.debug(f"{self.__class__.__name__} built query: {query}") - - return query - - def applyFilter(self): - query = self.buildQuery() - try: - self.model().setQuery(query) - self.filtered.emit(True) - except Exception as e: - logger.info(f"{self.__class__.__name__} {type(e).__name__} in query: {e}") - self.filtered.emit(False) - - @staticmethod - def format_query(query: str) -> str: - return query.translate(str.maketrans({'(': '\\(', ')': '\\)', "'": "\\'"})) - - # === Functionality related to setting the column delegates - def clearColumnDelegates(self): - for i in range(self.model().columnCount()): - self.setItemDelegateForColumn(i, None) - - def setDefaultColumnDelegates(self): - columns = self.model().columns() - for i, col_name in enumerate(columns): - if col_name in self.defaultColumnDelegates: - delegate = self.defaultColumnDelegates[col_name](self) - self.setItemDelegateForColumn(i, delegate) - # elif col_name.startswith("property_"): - # self.setItemDelegateForColumn(i, self.propertyDelegate) - - # === Functionality related to saving and restoring the View's state - - def saveState(self) -> dict: - if not self.model(): - return {} - - cols = self.model().columns() - - return { - "columns": cols, - "grouped_columns": [cols[i] for i in self.model().grouped_columns], - "visible_columns": [cols[i] for i in range(len(cols)) if not self.isColumnHidden(i)], - - "expanded_paths": list(self.expanded_paths), - - "filters": self.columnFilters, - "sort_column": cols[self.model().sort_column], - "sort_ascending": self.model().sort_order == Qt.SortOrder.AscendingOrder, - - "header_state": bytearray(self.header().saveState()).hex() - } - - def restoreSate(self, state: dict, dataframe: pd.DataFrame): - if not self.model(): - logger.debug(f"{self.__class__.__name__}: Model must first be set on the treeview before using restoreState") - return - - columns = list(dataframe.columns) - - self.model().beginResetModel() - - self.expanded_paths = set(tuple(p) for p in state.get("expanded_paths", [])) - self.columnFilters = {col: q for col, q in state.get("filters", {}).items() if col in columns} - - self.model().dataframe = dataframe - - self.model().grouped_columns = [columns.index(name) for name in state.get("grouped_columns", []) if name in columns] - self.model().filtered_columns = {columns.index(name) for name in self.columnFilters if name in columns} - - self.model().sort_column = columns.index(state.get("sort_column")) if state.get("sort_column") in columns else 0 - self.model().sort_order = Qt.SortOrder.AscendingOrder if state.get("sort_ascending") else Qt.SortOrder.DescendingOrder - - self.model()._query = self.buildQuery() - - self.model().endResetModel() - - match = True - for i, col in enumerate(state.get("columns", [])): - if i > len(columns) - 1: - match = False - break - if columns[i] != col: - match = False - break - - if match: - self.header().restoreState(bytearray.fromhex(state.get("header_state", ""))) - - for i, col in enumerate(columns): - self.setColumnHidden(i, col not in state.get("visible_columns", [col])) - - self.expand_after_reset() - - def expand_after_reset(self): - indices = [] - for path in self.expanded_paths: - try: - indices.append(self.model().indexFromPath(list(path))) - except KeyError: - continue - - for index in indices: - self.expand(index) - From 3072b3c3da65b344631bc9b9c1cdf5628f54dcb5 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 18 Nov 2025 13:39:08 +0100 Subject: [PATCH 127/267] Split up the parameter page for maintainability --- .../app/pages/parameters/__init__.py | 2 +- activity_browser/app/pages/parameters/base.py | 1197 ----------------- .../app/pages/parameters/parameter_models.py | 545 -------- .../app/pages/parameters/parameter_views.py | 284 ---- .../parameterized_exchanges_section.py | 275 ++++ .../app/pages/parameters/parameters.py | 522 +------ ...arameters_new.py => parameters_section.py} | 267 +--- 7 files changed, 332 insertions(+), 2760 deletions(-) delete mode 100644 activity_browser/app/pages/parameters/base.py delete mode 100644 activity_browser/app/pages/parameters/parameter_models.py delete mode 100644 activity_browser/app/pages/parameters/parameter_views.py create mode 100644 activity_browser/app/pages/parameters/parameterized_exchanges_section.py rename activity_browser/app/pages/parameters/{parameters_new.py => parameters_section.py} (58%) diff --git a/activity_browser/app/pages/parameters/__init__.py b/activity_browser/app/pages/parameters/__init__.py index 7a1d89bfd..dd02adad4 100644 --- a/activity_browser/app/pages/parameters/__init__.py +++ b/activity_browser/app/pages/parameters/__init__.py @@ -1,2 +1,2 @@ -from .parameters_new import ParametersPage +from .parameters import ParametersPage diff --git a/activity_browser/app/pages/parameters/base.py b/activity_browser/app/pages/parameters/base.py deleted file mode 100644 index fd99f3e5c..000000000 --- a/activity_browser/app/pages/parameters/base.py +++ /dev/null @@ -1,1197 +0,0 @@ -import os -import datetime -from typing import Optional, Any -from loguru import logger - -import arrow -import numpy as np -import pandas as pd -import bw2data as bd - -from qtpy.QtCore import QAbstractItemModel, QAbstractTableModel, QModelIndex, QSortFilterProxyModel -from qtpy import QtGui, QtWidgets -from qtpy.QtCore import QPoint, QRect, QSize, Qt, QTimer, Signal, Slot -from qtpy.QtWidgets import QApplication, QSizePolicy, QTableView - -from activity_browser.settings import ab_settings -from activity_browser.ui import icons, widgets, delegates - - - - -class ABSortProxyModel(QSortFilterProxyModel): - """Reimplementation to allow for sorting on the actual data in cells instead of the visible data. - - See this for context: https://github.com/LCA-ActivityBrowser/activity-browser/pull/1151 - """ - - def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool: - """Override to sort actual data, expects `left` and `right` are comparable. - - If `left` and `right` are not the same type, we check if numerical and empty string are compared, if that is the - case, we assume empty string == 0. - Added this case for: https://github.com/LCA-ActivityBrowser/activity-browser/issues/1215 - """ - left_data = self.sourceModel().data(left, "sorting") - right_data = self.sourceModel().data(right, "sorting") - - if not left_data and not right_data: - return True - if type(left_data) is type(right_data): - return left_data < right_data - - # comparing Falsys with types - if (isinstance(left_data, (int, float)) - and not right_data - ): # comparing left number with nothing, compare against '0' instead - return left_data < 0 - if (isinstance(left_data, str) - and not right_data - ): # comparing left str with nothing, compare against "" instead - return left_data < "" # note we use '>' instead of '<', content should be above empty fields - if (isinstance(right_data, (int, float)) - and not left_data - ): # comparing right number with nothing, compare against '0' instead - return 0 < right_data - if (isinstance(right_data, str) - and not left_data - ): # comparing right str with nothing, compare against "" instead - return right_data < "" # note we use '>' instead of '<', content should be above empty fields - - raise ValueError( - f"Cannot compare {left_data} and {right_data}, incompatible types." - ) - - -class ABDataFrameView(QtWidgets.QTableView): - """Base class for showing pandas dataframe objects as tables.""" - - ALL_FILTER = "All Files (*.*)" - CSV_FILTER = "CSV (*.csv);; All Files (*.*)" - TSV_FILTER = "TSV (*.tsv);; All Files (*.*)" - EXCEL_FILTER = "Excel (*.xlsx);; All Files (*.*)" - - def __init__(self, parent=None): - super().__init__(parent) - self.setVerticalScrollMode(QTableView.ScrollPerPixel) - self.setHorizontalScrollMode(QTableView.ScrollPerPixel) - - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) - - self.setWordWrap(True) - self.setAlternatingRowColors(True) - self.setSortingEnabled(True) - - self.horizontalHeader().setStretchLastSection(True) - self.horizontalHeader().setHighlightSections(False) - self.horizontalHeader().setDefaultAlignment(Qt.AlignLeft) - - self.verticalHeader().setDefaultSectionSize(22) # row height - self.verticalHeader().setVisible(False) - # Use a custom ViewOnly delegate by default. - # Can be overridden table-wide or per column in child classes. - self.setItemDelegate(delegates.ViewOnlyDelegate(self)) - - self.table_name = "LCA results" - # Initialize attributes which are set during the `sync` step. - # Creating (and typing) them here allows PyCharm to see them as - # valid attributes. - self.model: Optional[PandasModel] = None - self.proxy_model: Optional[ABSortProxyModel] = None - - def rowCount(self) -> int: - return 0 if self.model is None else self.model.rowCount() - - @Slot(name="updateProxyModel") - def update_proxy_model(self) -> None: - self.proxy_model = ABSortProxyModel(self) - self.proxy_model.setSourceModel(self.model) - self.proxy_model.setSortCaseSensitivity(Qt.CaseInsensitive) - self.setModel(self.proxy_model) - - @Slot(name="exportToClipboard") - def to_clipboard(self): - """Copy dataframe to clipboard""" - rows = list(range(self.model.rowCount())) - cols = list(range(self.model.columnCount())) - self.model.to_clipboard(rows, cols, include_header=True) - - def savefilepath( - self, default_file_name: str, caption: str = None, file_filter: str = None - ): - """Construct and return default path where data is stored - - Uses the application directory for AB - """ - safe_name = bd.utils.safe_filename(default_file_name, add_hash=False) - caption = caption or "Choose location to save lca results" - filepath, _ = QtWidgets.QFileDialog.getSaveFileName( - parent=self, - caption=caption, - dir=os.path.join(ab_settings.data_dir, safe_name), - filter=file_filter or self.ALL_FILTER, - ) - # getSaveFileName can now weirdly return Path objects. - return str(filepath) if filepath else filepath - - @Slot(name="exportToCsv") - def to_csv(self): - """Save the dataframe data to a CSV file.""" - filepath = self.savefilepath(self.table_name, file_filter=self.CSV_FILTER) - if filepath: - if not filepath.endswith(".csv"): - filepath += ".csv" - self.model.to_csv(filepath) - - @Slot(name="exportToExcel") - def to_excel(self, caption: str = None): - """Save the dataframe data to an excel file.""" - filepath = self.savefilepath( - self.table_name, caption, file_filter=self.EXCEL_FILTER - ) - if filepath: - if not filepath.endswith(".xlsx"): - filepath += ".xlsx" - self.model.to_excel(filepath) - - @Slot(QtGui.QKeyEvent, name="copyEvent") - def keyPressEvent(self, e): - """Allow user to copy selected data from the table - - NOTE: by default, the table headers (column names) are also copied. - """ - if e.modifiers() & Qt.ControlModifier: - # Should we include headers? - headers = e.modifiers() & Qt.ShiftModifier - if e.key() == Qt.Key_C: # copy - selection = [ - self.model.proxy_to_source(p) for p in self.selectedIndexes() - ] - rows = [index.row() for index in selection] - columns = [index.column() for index in selection] - rows = sorted(set(rows), key=rows.index) - columns = sorted(set(columns), key=columns.index) - self.model.to_clipboard(rows, columns, headers) - - -class ABFilterableDataFrameView(ABDataFrameView): - """Filterable base class for showing pandas dataframe objects as tables. - - To use this table, the following MUST be set in the table model: - - self.filterable_columns: dict - --> these columns are available for filtering - --> key is column name, value is column index - - To use this table, the following MUST be set in the table view: - - self.header.column_indices = list(self.model.filterable_columns.values()) - --> If not set, no filter buttons will appear. - --> Probably wise to set in a `if isinstance(self.model.filterable_columns, dict):` - --> This variable must be set any time the columns of the table change - - To use this table, the following can be set in the table model: - - self.different_column_types: dict - --> these columns require a different filter type than 'str' - --> e.g. self.different_column_types = {'col_name': 'num'} - """ - - FILTER_TYPES = { - "str": [ - "contains", - "does not contain", - "equals", - "does not equal", - "starts with", - "does not start with", - "ends with", - "does not end with", - ], - "str_tt": [ - "values in the column contain", - "values in the column do not contain", - "values in the column equal", - "values in the column do not equal", - "values in the column start with", - "values in the column do not start with", - "values in the column end with", - "values in the column do not end with", - ], - "num": ["=", "!=", ">=", "<=", "<= x <="], - "num_tt": [ - "values in the column equal", - "values in the column do not equal", - "values in the column are greater than or equal to", - "values in the column are smaller than or equal to", - "values in the column are between", - ], - } - - def __init__(self, parent=None): - super().__init__(parent) - - self.header = CustomHeader() - self.setHorizontalHeader(self.header) - - self.filters = None - self.different_column_types = {} - self.header.clicked.connect(self.header_filter_button_clicked) - self.selected_column = 0 - - # quick-filter setup: - self.prev_quick_filter = {} - self.debounce_quick_filter = QTimer() - self.debounce_quick_filter.setInterval(300) - self.debounce_quick_filter.setSingleShot(True) - self.debounce_quick_filter.timeout.connect(self.quick_filter) - - def header_filter_button_clicked(self, column: int, button: str) -> None: - self.selected_column = column - # this function is separate from the context menu in case we want to add right-click options later - if button == "LeftButton": - self.header_context_menu() - - def header_context_menu(self) -> None: - menu = QtWidgets.QMenu(self) - menu.setToolTipsVisible(True) - - col_type = self.model.different_column_types.get( - {v: k for k, v in self.model.filterable_columns.items()}[ - self.selected_column - ], - "str", - ) - - # quick-filter bar - self.input_line = QtWidgets.QLineEdit() - self.input_line.setFocusPolicy(Qt.StrongFocus) - if col_type == "num": - self.input_line.setValidator(QtGui.QDoubleValidator()) - search = QtWidgets.QToolButton() - search.setIcon(icons.qicons.search) - search.clicked.connect(menu.close) - quick_filter_layout = QtWidgets.QHBoxLayout() - quick_filter_layout.addWidget(self.input_line) - quick_filter_layout.addWidget(search) - quick_filter_widget = QtWidgets.QWidget() - quick_filter_widget.setLayout(quick_filter_layout) - quick_filter_widget.setToolTip( - "Filter this column on the input,\n" - "press 'enter' or the search button to filter" - ) - # write previous filter to the quick-filter input if we have one - if prev_filter := self.prev_quick_filter.get(self.selected_column, False): - self.input_line.setText(prev_filter[1]) - else: - self.input_line.setPlaceholderText("Quick filter ...") - self.input_line.textChanged.connect(self.debounce_quick_filter.start) - self.input_line.returnPressed.connect(menu.close) - QAline = QtWidgets.QWidgetAction(self) - QAline.setDefaultWidget(quick_filter_widget) - menu.addAction(QAline) - - # More filters submenu - mf_menu = QtWidgets.QMenu(menu) - mf_menu.setToolTipsVisible(True) - mf_menu.setIcon(icons.qicons.filter) - mf_menu.setTitle("More filters") - filter_actions = [] - for i, f in enumerate(self.FILTER_TYPES[col_type]): - fa = QtWidgets.QAction(text=f) - fa.setToolTip(self.FILTER_TYPES[col_type + "_tt"][i]) - fa.triggered.connect(self.simple_filter_dialog) - filter_actions.append(fa) - for fa in filter_actions: - mf_menu.addAction(fa) - menu.addMenu(mf_menu) - # edit filters main menu - filter_man = QtWidgets.QAction(icons.qicons.edit, "Manage filters") - filter_man.triggered.connect(self.filter_manager_dialog) - filter_man.setToolTip("Open the filter management menu") - menu.addAction(filter_man) - # delete column filters option - col_del = QtWidgets.QAction(icons.qicons.delete, "Remove column filters") - col_del.triggered.connect(self.reset_column_filters) - col_del.setToolTip("Remove all filters on this column") - menu.addAction(col_del) - col_del.setEnabled(False) - if isinstance(self.filters, dict) and self.filters.get( - self.selected_column, False - ): - col_del.setEnabled(True) - # delete all filters option - all_del = QtWidgets.QAction(icons.qicons.delete, "Remove all filters") - all_del.triggered.connect(self.reset_filters) - all_del.setToolTip("Remove all filters in this table") - menu.addAction(all_del) - all_del.setEnabled(False) - if isinstance(self.filters, dict): - all_del.setEnabled(True) - - # Show existing filters for column - if isinstance(self.filters, dict) and self.filters.get( - self.selected_column, False - ): - menu.addSeparator() - active_filters_label = QtWidgets.QAction( - icons.qicons.filter, "Active column filters:" - ) - active_filters_label.setEnabled(False) - menu.addAction(active_filters_label) - active_filters = [] - for filter_data in self.filters[self.selected_column]["filters"]: - if filter_data[0] == "<= x <=": - q = " and ".join(filter_data[1]) - else: - q = filter_data[1] - filter_str = ": ".join([filter_data[0], q]) - f = QtWidgets.QAction(text=filter_str) - f.setEnabled(False) - active_filters.append(f) - for f in active_filters: - menu.addAction(f) - - self.input_line.setFocus() - loc = self.header.event_pos - menu.exec_(self.mapToGlobal(loc)) - - @Slot(name="updateProxyModel") - def update_proxy_model(self) -> None: - self.proxy_model = ABMultiColumnSortProxyModel(self) - self.proxy_model.setSourceModel(self.model) - self.proxy_model.setSortCaseSensitivity(Qt.CaseInsensitive) - self.setModel(self.proxy_model) - - def quick_filter(self) -> None: - # remove weird whitespace from input - query = ( - self.input_line.text().translate(str.maketrans("", "", "\n\t\r")).strip() - ) - - # convert to filter - col_name = {v: k for k, v in self.model.filterable_columns.items()}[ - self.selected_column - ] - if self.model.different_column_types.get(col_name): - # column is type 'num' - filt = ("=", query) - else: - # column is type 'str' - filt = ("contains", query, False) - # check if quick filter exists for this col, if so; remove from self.filters - if prev_filter := self.prev_quick_filter.get(self.selected_column, False): - self.filters[self.selected_column]["filters"].remove(prev_filter) - - # place the filter in self.prev_quick_filter for next quick filter on this column - self.prev_quick_filter[self.selected_column] = filt - - # apply the right filters - if query != "": - # the query is not empty, add it to the filters and apply them - self.add_filter(filt) - self.apply_filters() - elif len(self.filters[self.selected_column]["filters"]) > 0: - # the query is empty, but there are still filters for this column, so apply the filters - self.apply_filters() - else: - # the query is empty, and there are no more filters for this column, reset this filter. - self.reset_column_filters() - - def filter_manager_dialog(self) -> None: - # get right data - column_names = self.model.filterable_columns - - # show dialog - dialog = widgets.dialog.FilterManagerDialog( - column_names=column_names, - filters=self.filters, - filter_types=self.FILTER_TYPES, - selected_column=self.selected_column, - column_types=self.model.different_column_types, - ) - if dialog.exec_() == widgets.dialog.FilterManagerDialog.Accepted: - # set the filters - filters = dialog.get_filters - if filters != self.filters: - # the filters returned from the dialog are different, actually apply the filters - rm = [] - for col, qf in self.prev_quick_filter.items(): - # check if quickfilters exist for these columns, otherwise remove them - if ( - filters.get(col, False) and qf not in filters[col]["filters"] - ) or not filters.get(col, False): - rm.append(col) - for col in rm: - self.prev_quick_filter.pop(col) - self.write_filters(filters) - self.apply_filters() - - def simple_filter_dialog(self, preset_type: str = None) -> None: - if not preset_type: - preset_type = self.sender().text() - - # get right data - column_name = {v: k for k, v in self.model.filterable_columns.items()}[ - self.selected_column - ] - col_type = self.model.different_column_types.get(column_name, "str") - - # show dialog - dialog = widgets.dialog.SimpleFilterDialog( - column_name=column_name, - filter_types=self.FILTER_TYPES, - column_type=col_type, - preset_type=preset_type, - ) - if dialog.exec_() == widgets.dialog.SimpleFilterDialog.Accepted: - new_filter = dialog.get_filter - # add the filter to existing filters - if new_filter: - self.add_filter(new_filter) - self.apply_filters() - - def add_filter(self, new_filter: tuple) -> None: - """Add a single filter to self.filters.""" - if isinstance(self.filters, dict): - # filters exist - all_filters = self.filters - if all_filters.get(self.selected_column, False): - # filters exist for this column - all_filters[self.selected_column]["filters"].append(new_filter) - if ( - not all_filters[self.selected_column].get("mode", False) - and len(all_filters[self.selected_column]["filters"]) > 1 - ): - # a mode does not exist, but there are multiple filters - all_filters[self.selected_column]["mode"] = "OR" - else: - # filters don't yet exist for this column: - all_filters[self.selected_column] = {"filters": [new_filter]} - else: - # no filters exist - all_filters = { - self.selected_column: {"filters": [new_filter]}, - "mode": "AND", - } - - self.write_filters(all_filters) - - def write_filters(self, filters: dict) -> None: - self.filters = filters - - def apply_filters(self) -> None: - if self.filters: - QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - # only allow filters that are for columns that may be filtered on - filters = { - k: v - for k, v in self.filters.items() - if k in list(self.model.filterable_columns.values()) + ["mode"] - } - self.proxy_model.set_filters(self.model.get_filter_mask(filters)) - self.header.has_active_filters = list(filters.keys()) - QtWidgets.QApplication.restoreOverrideCursor() - else: - self.reset_filters() - - def reset_column_filters(self) -> None: - """Reset all filters for this column.""" - QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - f = self.filters - if f.get(self.selected_column, False): - f.pop(self.selected_column) - if self.prev_quick_filter.get(self.selected_column, False): - self.prev_quick_filter.pop(self.selected_column) - self.write_filters(f) - if len(self.filters) == 1 and self.filters.get("mode"): - # the only thing in filters remaining is the mode --> there are no filters - self.reset_filters() - else: - self.header.has_active_filters = list(self.filters.keys()) - self.apply_filters() - QtWidgets.QApplication.restoreOverrideCursor() - - def reset_filters(self) -> None: - """Reset all filters for this entire table.""" - QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - self.write_filters(None) - self.header.has_active_filters = [] - self.prev_quick_filter = {} - self.proxy_model.clear_filters() - QtWidgets.QApplication.restoreOverrideCursor() - - -class CustomHeader(QtWidgets.QHeaderView): - """Header which has a filter button on each cell that can trigger a signal. - - Largely based on https://stackoverflow.com/a/30938728 - """ - - clicked = Signal(int, str) - - _x_offset = 0 - _y_offset = ( - 0 # This value is calculated later, based on the height of the paint rect - ) - _width = 18 - _height = 18 - - def __init__(self, orientation=Qt.Horizontal, parent=None): - super(CustomHeader, self).__init__(orientation, parent) - self.setSectionsClickable(True) - - self.column_indices = [] - self.has_active_filters = [] # list of column indices that have filters active - self.event_pos = None - - def paintSection(self, painter, rect, logical_index): - """Paint the button onto the column header.""" - painter.save() - super(CustomHeader, self).paintSection(painter, rect, logical_index) - painter.restore() - - self._y_offset = int(rect.height() - self._width) - - if logical_index in self.column_indices: - option = QtWidgets.QStyleOptionButton() - option.rect = QRect( - rect.x() + self._x_offset, - rect.y() + self._y_offset, - self._width, - self._height, - ) - option.state = ( - QtWidgets.QStyle.State_Enabled | QtWidgets.QStyle.State_Active - ) - - # put the filter icon onto the label - if logical_index in self.has_active_filters: - option.icon = icons.qicons.filter - else: - option.icon = icons.qicons.filter_outline - option.iconSize = QSize(16, 16) - - # set the settings to a PushButton - self.style().drawControl(QtWidgets.QStyle.CE_PushButton, option, painter) - - def mousePressEvent(self, event): - index = self.logicalIndexAt(event.pos()) - if index in self.column_indices: - x = self.sectionPosition(index) - if ( - x + self._x_offset < event.pos().x() < x + self._x_offset + self._width - and self._y_offset < event.pos().y() < self._y_offset + self._height - ): - # the button is clicked - - # set the position of the lower left point of the filter button to spawn a menu - pos = QPoint() - pos.setX(x + self._x_offset + self._width) - pos.setY(self._y_offset + self._height) - self.event_pos = pos - - # emit the column index and the button (left/right) pressed - self.clicked.emit(index, str(event.button()).split(".")[-1]) - else: - # pass the event to the header (for sorting) - super(CustomHeader, self).mousePressEvent(event) - else: - # pass the event to the header (for sorting) - super(CustomHeader, self).mousePressEvent(event) - self.viewport().update() - - -class ABMultiColumnSortProxyModel(ABSortProxyModel): - """Subclass of QSortFilterProxyModel to enable sorting on multiple columns. - - The main purpose of this subclass is to override def filterAcceptsRow(). - - Subclass based on various ideas from: - https://stackoverflow.com/questions/47201539/how-to-filter-multiple-column-in-qtableview - http://www.dayofthenewdan.com/2013/02/09/Qt_QSortFilterProxyModel.html - https://gist.github.com/dbridges/4732790 - """ - - def __init__(self, parent=None): - super(ABMultiColumnSortProxyModel, self).__init__(parent) - - # the filter mask, an iterable array with boolean values on whether or not to keep the row - self.mask = None - - # metric to keep track of successful matches on filter - self.matches = 0 - - # custom filter activation - self.activate_filter = False - - def set_filters(self, mask) -> None: - self.mask = mask - self.matches = 0 - self.activate_filter = True - self.invalidateFilter() - self.activate_filter = False - logger.info("{} filter matches found".format(self.matches)) - - def clear_filters(self) -> None: - self.mask = None - self.invalidateFilter() - - def filterAcceptsRow(self, row: int, parent) -> bool: - # check if self.activate_filter is enabled, else return True - if not self.activate_filter: - return True - # get the right index from the mask - matched = self.mask.iloc[row] - if matched: - self.matches += 1 - return matched - - -class ABDictTreeView(QtWidgets.QTreeView): - def __init__(self, parent=None): - super().__init__(parent) - self.setUniformRowHeights(True) - self.data = {} - - @Slot(name="resizeView") - def custom_view_sizing(self) -> None: - """Resize the first column (usually 'name') whenever an item is - expanded or collapsed. - """ - self.resizeColumnToContents(0) - - @Slot(name="expandSelectedBranch") - def expand_branch(self): - """Expand selected branch.""" - index = self.currentIndex() - self.expand_or_collapse(index, True) - - @Slot(name="collapseSelectedBranch") - def collapse_branch(self): - """Collapse selected branch.""" - index = self.currentIndex() - self.expand_or_collapse(index, False) - - def expand_or_collapse(self, index, expand): - """Expand or collapse branch. - - Will expand or collapse any branch and sub-branches given in index. - expand is a boolean that defines expand (True) or collapse (False).""" - - # based on: https://stackoverflow.com/a/4208240 - def recursive_expand_or_collapse(index, childCount, expand): - for childNo in range(0, childCount): - childIndex = index.child(childNo, 0) - if expand: # if expanding, do that first (wonky animation otherwise) - self.setExpanded(childIndex, expand) - subChildCount = childIndex.internalPointer().childCount() - if subChildCount > 0: - recursive_expand_or_collapse(childIndex, subChildCount, expand) - if not expand: # if collapsing, do it last (wonky animation otherwise) - self.setExpanded(childIndex, expand) - - QApplication.setOverrideCursor(Qt.WaitCursor) - if not expand: # if collapsing, do that first (wonky animation otherwise) - self.setExpanded(index, expand) - childCount = index.internalPointer().childCount() - recursive_expand_or_collapse(index, childCount, expand) - if expand: # if expanding, do that last (wonky animation otherwise) - self.setExpanded(index, expand) - QApplication.restoreOverrideCursor() - - -class PandasModel(QAbstractTableModel): - """Abstract pandas table model adapted from - https://stackoverflow.com/a/42955764. - """ - - HEADERS = [] - updated = Signal() - - def __init__(self, df: pd.DataFrame = None, parent=None): - super().__init__(parent) - self._dataframe: Optional[pd.DataFrame] = df - self.filterable_columns = None - self.different_column_types = {} - # The list of columns which should be editable by the builtin checkbox editor - # The value of the dict holds whether the value should also be displayed as text - self._checkbox_editors: dict[int, tuple[bool, Any, Any]] = {} - self._columns: list[str] = [] - - @property - def columns(self) -> list[str]: - if self._dataframe is not None: - return self._dataframe.columns - return [] - - def rowCount(self, parent=None, *args, **kwargs): - return 0 if self._dataframe is None else self._dataframe.shape[0] - - def columnCount(self, parent=None, *args, **kwargs): - return 0 if self._dataframe is None else self._dataframe.shape[1] - - def data(self, index, role=Qt.DisplayRole): - """ - Return value for table index based on a certain DisplayRole enum. - - More on DisplayRole enums: https://doc.qt.io/qt-5/qt.html#ItemDataRole-enum - """ - if not index.isValid(): - return None - # instantiate value only in case of DisplayRole or ToolTipRole - value = None - tt_date_flag = False # flag to indicate if value is datetime object and role is ToolTipRole - if role in [Qt.DisplayRole, Qt.ToolTipRole, "sorting", Qt.EditRole]: - value = self._dataframe.iat[index.row(), index.column()] - if isinstance(value, np.float64): - value = float(value) - elif isinstance(value, bool): - value = str(value) - elif isinstance(value, np.int64): - value = value.item() - elif isinstance(value, tuple): - value = str(value) - elif isinstance(value, datetime.datetime) and ( - Qt.DisplayRole or Qt.ToolTipRole - ): - tz = datetime.datetime.now(datetime.timezone.utc).astimezone() - time_shift = -tz.utcoffset().total_seconds() - if role == Qt.ToolTipRole: - value = ( - arrow.get(value) - .shift(seconds=time_shift) - .format("YYYY-MM-DD HH:mm:ss") - ) - tt_date_flag = True - elif role == Qt.DisplayRole: - value = arrow.get(value).shift(seconds=time_shift).humanize() - - # Handle checkbox editors - # Checkbox editors can return two values for one cell: the usual display value - # and a checked / not checked enum. It is useful to return both, when the - # underlying data is not bool, but text to visualize eventual errors. - if index.column() in self._checkbox_editors: - if role == Qt.ItemDataRole.CheckStateRole: - value = self._dataframe.iat[index.row(), index.column()] - if isinstance(value, str): - logger.error(f"Expected bool, received str: {value}!!") - true_value = self._checkbox_editors[index.column()][1] - # Convert the data to an appropriate value for the checkbox - return Qt.CheckState.Checked if value == true_value else Qt.CheckState.Unchecked - display_value = self._checkbox_editors[index.column()][0] - if role == Qt.ItemDataRole.DisplayRole and not display_value: - return None - - # immediately return value in case of DisplayRole or sorting - if role == Qt.DisplayRole or role == "sorting": - return value - - # in case of ToolTipRole and date, always show the full date - if tt_date_flag and role == Qt.ToolTipRole: - return value - - # in case of ToolTipRole, check whether content fits the cell - if role == Qt.ToolTipRole: - parent = self.parent() - fontMetrics = parent.fontMetrics() - - # get the width of both the cell, and the text - column_width = parent.columnWidth(index.column()) - text_width = fontMetrics.horizontalAdvance(str(value)) - margin = 10 - - # only show tooltip if the text is wider then the cell minus the margin - if text_width > column_width - margin: - return value - - return None - - def flags(self, index): - return Qt.ItemIsSelectable | Qt.ItemIsEnabled - - def headerData(self, section, orientation, role=Qt.DisplayRole): - if orientation == Qt.Horizontal and role == Qt.DisplayRole: - return self._dataframe.columns[section] - elif orientation == Qt.Vertical and role == Qt.DisplayRole: - return self._dataframe.index[section] - return None - - def row_data(self, index: int) -> list: - """Return the row at index as a list.""" - return self._dataframe.iloc[index, :].tolist() - - def to_clipboard(self, rows, columns, include_header: bool = False): - """Copy the given rows and columns of the dataframe to clipboard""" - self._dataframe.iloc[rows, columns].to_clipboard( - index=False, header=include_header - ) - - def to_csv(self, path: str) -> None: - """Store the dataframe as csv in the given path.""" - self._dataframe.to_csv(path) - - def to_excel(self, path: str) -> None: - """Store the underlying dataframe as excel in the given path""" - self._dataframe.to_excel(excel_writer=path) - - def sync(self, *args, **kwargs) -> None: - """(Re)build the dataframe according to the given arguments.""" - self._dataframe = pd.DataFrame([], columns=self.HEADERS) - - @staticmethod - def proxy_to_source(proxy: QModelIndex) -> QModelIndex: - """Step from the QSortFilterProxyModel to the underlying PandasModel.""" - model = proxy.model() - if not hasattr(model, "mapToSource"): - return proxy # Proxy is actually the PandasModel - return model.mapToSource(proxy) - - def test_query_on_column( - self, test_type: str, col_data: pd.Series, query - ) -> pd.Series: - """Compare query and col_data on test_type, return array with boolean test results.""" - if test_type == "equals": - return col_data == query - elif test_type == "does not equal": - return col_data != query - elif test_type == "contains": - return col_data.str.contains(query, regex=False) - elif test_type == "does not contain": - return ~col_data.str.contains(query, regex=False) - elif test_type == "starts with": - return col_data.str.startswith(query) - elif test_type == "does not start with": - return ~col_data.str.startswith(query) - elif test_type == "ends with": - return col_data.str.endswith(query) - elif test_type == "does not end with": - return ~col_data.str.endswith(query) - elif test_type == "=": - return col_data.astype(float) == float(query) - elif test_type == "!=": - return col_data.astype(float) != float(query) - elif test_type == ">=": - return col_data.astype(float) >= float(query) - elif test_type == "<=": - return col_data.astype(float) <= float(query) - elif test_type == "<= x <=": - return (float(query[0]) <= col_data.astype(float)) & ( - col_data.astype(float) <= float(query[1]) - ) - else: - logger.warning("unknown filter type >{}<, assuming 'EQUALS'".format(test_type)) - return col_data == query - - def get_filter_mask(self, filters: dict) -> pd.Series: - """Generate a filter mask of the dataframe based on the filters. - - Returns a pd.Series of boolean results (the mask). - """ - # get the column name from index - fc_rev = {v: k for k, v in self.filterable_columns.items()} - - all_mode = filters["mode"] - all_mask = None - # iterate over columns - for col_idx, col_filters in filters.items(): - if col_idx == "mode": - continue - col_name = fc_rev[col_idx] - col_data = self._dataframe[col_name] - col_mode = col_filters.get("mode", False) - col_mask = None - # iterate over filters within column - for col_filt in col_filters["filters"]: - if self.different_column_types.get(col_name, False): - # this is a 'num' column - filt_type, query = col_filt - col_data_ = col_data - else: - # this is a 'str' column - filt_type, query, case_sensitive = col_filt - if case_sensitive: - col_data_ = col_data.astype(str) - else: - col_data_ = col_data.astype(str).str.upper() - query = query.upper() - - # run the test - new_mask = self.test_query_on_column(filt_type, col_data_, query) - if not any(new_mask): - # no matches for this mask, let user know: - logger.info( - "There were no matches for filter: {}: '{}'".format( - col_filt[0], col_filt[1] - ) - ) - - # create or combine new mask within column - if isinstance(col_mask, pd.Series) and col_mode == "AND": - col_mask = col_mask & new_mask - elif isinstance(col_mask, pd.Series) and col_mode == "OR": - col_mask = col_mask + new_mask - else: - col_mask = new_mask - - # create or combine new mask on columns - if isinstance(all_mask, pd.Series) and all_mode == "AND": - all_mask = all_mask & col_mask - elif isinstance(all_mask, pd.Series) and all_mode == "OR": - all_mask = all_mask + col_mask - else: - all_mask = col_mask - return all_mask - - def set_read_only(self, read_only: bool): - """Interface function, to support editable models""" - pass - - def is_read_only(self) -> bool: - """Interface function, to support editable models""" - return True - - def set_builtin_checkbox_delegate(self, column: int, show_text_value: bool, - true_value: Any = True, false_value: Any = False): - """ - Enables the builtin checkbox delegate for columns. - Can be used on bool values only. - As the underlying data can be bool or string, we provide the values to be - stored as parameters. - """ - self._checkbox_editors[column] = (show_text_value, true_value, false_value) - - -class EditablePandasModel(PandasModel): - """Allows underlying dataframe to be edited through Delegate classes.""" - - def __init__(self, df: pd.DataFrame = None, parent=None): - super().__init__(df, parent) - self._read_only = True - # The list of columns which should always be read-only - self._read_only_columns: list[int] = [] - - def flags(self, index: QModelIndex) -> Qt.ItemFlags: - """Returns ItemIsEditable flag only if the model is not read only - This prevents editing of data on QAbstractTableModel level. - """ - if index.isValid(): - result = super().flags(index) - if not self._read_only and not index.column() in self._read_only_columns: - result |= Qt.ItemIsEditable - # Qt.ItemIsUserCheckable is also editable, it allows the clicking - # of the checkbox - if index.column() in self._checkbox_editors: - result |= Qt.ItemIsUserCheckable - return result - return Qt.ItemFlag.NoItemFlags - - def prepare_set_value(self, index: QModelIndex, value: Any, - role: int = Qt.EditRole) -> tuple[Any, bool]: - check_ok = False - if index.isValid(): - if role == Qt.CheckStateRole and index.column() in self._checkbox_editors: - true_value = self._checkbox_editors[index.column()][1] - false_value = self._checkbox_editors[index.column()][2] - value = true_value if value == Qt.CheckState.Checked else false_value - check_ok = True - return (value, check_ok) - - def setData(self, index, value, role=Qt.EditRole): - """Inserts the given validated data into the given index""" - if index.isValid(): - value, check_ok = self.prepare_set_value(index, value, role) - if role == Qt.EditRole or check_ok: - self._dataframe.iat[index.row(), index.column()] = value - self.dataChanged.emit(index, index, [role]) - return True - return False - - def set_read_only(self, read_only: bool): - """Allows to set the model to editable""" - self._read_only = read_only - - def is_read_only(self) -> bool: - """Returns if the model is editable""" - return self._read_only - - def insertRows(self, position, rows=1, parent=QModelIndex()): - """Add new rows to the underlying dataframe""" - self.beginInsertRows(parent, position, position + rows - 1) - new_rows = pd.DataFrame( - [[None] * self.columnCount()] * rows, columns=self._dataframe.columns - ) - self._dataframe = pd.concat( - [self._dataframe.iloc[:position], new_rows, self._dataframe.iloc[position:]] - ).reset_index(drop=True) - self.endInsertRows() - return True - - def removeRows(self, position, rows=1, parent=QModelIndex()): - """Remove rows from the underlying dataframe""" - self.beginRemoveRows(parent, position, position + rows - 1) - self._dataframe = self._dataframe.drop( - self._dataframe.index[position : position + rows] - ).reset_index(drop=True) - self.endRemoveRows() - return True - - def set_readonly_column(self, column: int): - if column not in self._read_only_columns: - self._read_only_columns.append(column) - - -# Take the classes defined above and add the ItemIsDragEnabled flag -class DragPandasModel(PandasModel): - """Same as PandasModel, but enabling dragging.""" - - def flags(self, index): - return super().flags(index) | Qt.ItemIsDragEnabled - - -class EditableDragPandasModel(EditablePandasModel): - def flags(self, index): - return super().flags(index) | Qt.ItemIsDragEnabled - - -class TreeItem(object): - __slots__ = ["_data", "_parent", "_children"] - - def __init__(self, data: list, parent=None): - self._data = data - self._parent = parent - self._children = [] - - @classmethod - def build_root(cls, cols: list) -> "TreeItem": - return cls(cols) - - def clear(self) -> None: - """Use this method to recursively prune a branch from a tree model. - When called on the root item, removes the entire tree. - - Make sure to only use this in conjunction with model.beginModelReset - and model.endModelReset to avoid python crashing. - """ - for c in self._children: - c.clear() - self._children = [] - - def appendChild(self, item) -> None: - self._children.append(item) - - def child(self, row: int) -> "TreeItem": - return self._children[row] - - @property - def children(self) -> list: - return self._children - - def childCount(self) -> int: - return len(self._children) - - def data(self, column: int): - return self._data[column] - - def parent(self) -> Optional["TreeItem"]: - return self._parent - - def row(self) -> int: - return self._parent.children.index(self) if self._parent else 0 - - def __repr__(self) -> str: - return "({})".format(", ".join(str(x) for x in self._data)) - - -class BaseTreeModel(QAbstractItemModel): - """Base Model used to present data for QTreeView.""" - - HEADERS = [] - updated = Signal() - - def __init__(self, parent=None, *args, **kwargs): - super().__init__(parent) - self.root = None - self._data = {} - - def columnCount(self, parent: QModelIndex = None, *args, **kwargs) -> int: - return len(self.HEADERS) - - def data(self, index, role: int = Qt.DisplayRole): - if not index.isValid(): - return None - - if role == Qt.DisplayRole: - item = index.internalPointer() - return str(item.data(index.column())) - - def headerData(self, column, orientation, role: int = Qt.DisplayRole): - if orientation == Qt.Horizontal and role == Qt.DisplayRole: - try: - return self.HEADERS[column] - except IndexError: - pass - return None - - def index(self, row: int, column: int, parent: QModelIndex = None, *args, **kwargs): - if not self.hasIndex(row, column, parent): - return QModelIndex() - - parent = parent.internalPointer() if parent.isValid() else self.root - child = parent.child(row) - if child: - return self.createIndex(row, column, child) - else: - return QModelIndex() - - def iterator(self, item: TreeItem = None): - """ - An iterator for the TreeModel items, providing an initial object of type - None returns a series of objects contained in the TreeModel (including the - root item as the first returned object). Returns a final None type object - upon termination. - """ - if item == None: - return self.root - if item.childCount() > 0: # if its not a leaf - return item.child(0) # return the first child - if item == self.root: - return - if item.parent().childCount() > item.row() + 1: # if there's still a sibling - return item.parent().child(item.row() + 1) - else: # look for siblings from previous "generations" - parent = item.parent() - while parent != self.root: - if parent.parent().childCount() > parent.row() + 1: - return parent.parent().child(parent.row() + 1) - parent = parent.parent() - # if there are no siblings left return None - return None - - def parent(self, child: QModelIndex = None): - if not child.isValid(): - return QModelIndex() - - child = child.internalPointer() - parent = child.parent() - if parent == self.root: - return QModelIndex() - - return self.createIndex(parent.row(), 0, parent) - - def rowCount(self, parent=None, *args, **kwargs): - if not parent or parent.column() > 0: - return 0 - parent = parent.internalPointer() if parent.isValid() else self.root - return parent.childCount() - - def flags(self, index): - if not index.isValid(): - return Qt.NoItemFlags - - return Qt.ItemIsEnabled | Qt.ItemIsSelectable - - def setup_model_data(self) -> None: - """Method used to construct the tree of items for the model.""" - raise NotImplementedError - - def sync(self, *args, **kwargs) -> None: - pass - diff --git a/activity_browser/app/pages/parameters/parameter_models.py b/activity_browser/app/pages/parameters/parameter_models.py deleted file mode 100644 index cbc25e8df..000000000 --- a/activity_browser/app/pages/parameters/parameter_models.py +++ /dev/null @@ -1,545 +0,0 @@ -import itertools -from typing import Iterable, Tuple -from loguru import logger - -import pandas as pd -import numpy as np -from asteval import Interpreter -from peewee import DoesNotExist - -from qtpy import QtWidgets -from qtpy.QtCore import QModelIndex, Slot - -from bw2data.parameters import ActivityParameter, DatabaseParameter, Group, ProjectParameter - -from activity_browser import app, signals, application -from activity_browser.bwutils.utils import Parameters -from activity_browser.mod import bw2data as bd -from activity_browser.ui.dialogs import UncertaintyWizard - -from .base import BaseTreeModel, EditablePandasModel, TreeItem, PandasModel - - - - -class BaseParameterModel(EditablePandasModel): - COLUMNS = [] - UNCERTAINTY = ["uncertainty type", "loc", "scale", "shape", "minimum", "maximum"] - - def __init__(self, parent=None): - super().__init__(parent=parent) - self.param_col = 0 - self.comment_col = 0 - self.dataChanged.connect(self.edit_single_parameter) - self.set_read_only(False) - - signals.project.changed.connect(self.sync) - signals.parameter.changed.connect(self.sync) - signals.parameter.recalculated.connect(self.sync) - - def get_parameter(self, proxy: QModelIndex) -> object: - idx = self.proxy_to_source(proxy) - return self._dataframe.iat[idx.row(), self.param_col] - - def get_key(self, *args) -> tuple: - """Use this to build a (partial) key for the current index.""" - return "", "" - - def get_group(self, *args) -> str: - """Retrieve the group of the parameter currently selected.""" - return "project" - - @classmethod - def parse_parameter(cls, parameter) -> dict: - """Take the given Parameter object and extract data for a single - row in the table dataframe - - If the parameter has uncertainty data, include this as well. - """ - row = {key: getattr(parameter, key, "") for key in cls.COLUMNS} - data = getattr(parameter, "data", {}) - row.update(cls.extract_uncertainty_data(data)) - row["parameter"] = parameter - row["comment"] = data.get("comment", "") - return row - - @classmethod - def columns(cls) -> list: - """Combine COLUMNS, UNCERTAINTY and add 'parameter'.""" - return cls.COLUMNS + cls.UNCERTAINTY + ["parameter"] - - @classmethod - def extract_uncertainty_data(cls, data: dict) -> dict: - """This helper function can be used to extract specific uncertainty - columns from the parameter data - - See: - https://2.docs.brightway.dev/intro.html#storing-uncertain-values - https://stats-arrays.readthedocs.io/en/latest/#mapping-parameter-array-columns-to-uncertainty-distributions - """ - row = {key: data.get(key) for key in cls.UNCERTAINTY} - return row - - @Slot(QModelIndex, name="editSingleParameter") - def edit_single_parameter(self, index: QModelIndex) -> None: - """Take the index and update the underlying brightway Parameter.""" - param = self.get_parameter(index) - field = self._dataframe.columns[index.column()] - - app.actions.ParameterModify.run(param, field, index.data()) - - - @Slot(QModelIndex, name="startRenameParameter") - def handle_parameter_rename(self, proxy: QModelIndex) -> None: - group = self.get_group(proxy) - param = self.get_parameter(proxy) - - app.actions.ParameterRename.run(param) - - def delete_parameter(self, proxy: QModelIndex) -> None: - param = self.get_parameter(proxy) - app.actions.ParameterDelete.run(param) - - @Slot(name="modifyParameterUncertainty") - def modify_uncertainty(self, proxy: QModelIndex) -> None: - param = self.get_parameter(proxy) - wizard = UncertaintyWizard(param, self.parent()) - wizard.show() - - @Slot(name="unsetParameterUncertainty") - def remove_uncertainty(self, proxy: QModelIndex) -> None: - param = self.get_parameter(proxy) - app.actions.ParameterUncertaintyRemove.run(param) - - def handle_double_click(self, proxy: QModelIndex) -> None: - column = proxy.column() - if self._dataframe.columns[column] in BaseParameterModel.UNCERTAINTY: - self.modify_uncertainty(proxy) - elif self._dataframe.columns[column] == "name": - self.handle_parameter_rename(proxy) - - -class ProjectParameterModel(BaseParameterModel): - COLUMNS = ["name", "amount", "formula", "comment"] - - def sync(self) -> None: - data = [self.parse_parameter(p) for p in ProjectParameter.select()] - self._dataframe = pd.DataFrame(data, columns=self.columns()) - self.param_col = self._dataframe.columns.get_loc("parameter") - self.comment_col = self._dataframe.columns.get_loc("comment") - self.updated.emit() - - @staticmethod - def get_usable_parameters() -> Iterable[list]: - return ([k, v, "project"] for k, v in ProjectParameter.static().items()) - - @staticmethod - def get_interpreter() -> Interpreter: - interpreter = Interpreter() - interpreter.symtable.update(ProjectParameter.static()) - return interpreter - - -class DatabaseParameterModel(BaseParameterModel): - COLUMNS = ["name", "amount", "formula", "database", "comment"] - - def __init__(self, parent=None): - super().__init__(parent) - self.db_col = 0 - - def sync(self) -> None: - data = [self.parse_parameter(p) for p in DatabaseParameter.select()] - self._dataframe = pd.DataFrame(data, columns=self.columns()) - self.db_col = self._dataframe.columns.get_loc("database") - self.param_col = self._dataframe.columns.get_loc("parameter") - self.comment_col = self._dataframe.columns.get_loc("comment") - self.updated.emit() - - def get_key(self, proxy: QModelIndex = None) -> tuple: - return self.get_database(proxy), "" - - def get_group(self, proxy: QModelIndex = None) -> str: - """Retrieve the group of the activity currently selected.""" - return self.get_database(proxy) - - @staticmethod - def get_usable_parameters(): - """Include the project parameters, and generate database parameters.""" - project = ProjectParameterModel.get_usable_parameters() - database = ( - [p.name, p.amount, "database ({})".format(p.database)] - for p in DatabaseParameter.select() - ) - return itertools.chain(project, database) - - def get_database(self, proxy: QModelIndex = None) -> str: - """Return the database name of the parameter currently selected.""" - idx = self.proxy_to_source(proxy or self.parent().currentIndex()) - return self._dataframe.iat[idx.row(), self.db_col] - - def get_interpreter(self) -> Interpreter: - """Take the interpreter from the ProjectParameterTable and add - (potentially overwriting) all database symbols for the selected index. - """ - interpreter = ProjectParameterModel.get_interpreter() - db_name = self.get_database() - interpreter.symtable.update(DatabaseParameter.static(db_name)) - return interpreter - - -class ActivityParameterModel(BaseParameterModel): - COLUMNS = [ - "name", - "amount", - "formula", - "product", - "activity", - "location", - "group", - "order", - "key", - "comment", - ] - - def __init__(self, parent=None): - super().__init__(parent) - self.group_col = 0 - self.key_col = 0 - self.order_col = 0 - - def sync(self) -> None: - """Build a dataframe using the ActivityParameters set in brightway""" - generate = ( - self.parse_parameter(p) - for p in ( - ActivityParameter.select(ActivityParameter, Group.order) - .join(Group, on=(ActivityParameter.group == Group.name)) - .namedtuples() - ) - ) - data = [x for x in generate if "key" in x] - self._dataframe = pd.DataFrame(data, columns=self.columns()) - # Convert the 'order' column from list into string - self._dataframe["order"] = self._dataframe["order"].apply(", ".join) - self.group_col = self._dataframe.columns.get_loc("group") - self.param_col = self._dataframe.columns.get_loc("parameter") - self.key_col = self._dataframe.columns.get_loc("key") - self.order_col = self._dataframe.columns.get_loc("order") - self.comment_col = self._dataframe.columns.get_loc("comment") - self.updated.emit() - - @classmethod - def parse_parameter(cls, parameter) -> dict: - """Override the base method to add more steps.""" - row = super().parse_parameter(parameter) - # Combine the 'database' and 'code' fields of the parameter into a 'key' - row["key"] = (parameter.database, parameter.code) - try: - act = bd.get_activity(row["key"]) - except: - # Can occur if an activity parameter exists for a removed activity. - logger.info( - "Activity {} no longer exists, removing parameter.".format(row["key"]) - ) - app.actions.ParameterClearBroken.run(parameter) - return {} - row["product"] = act.get("reference product") or act.get("name") - row["activity"] = act.get("name") - row["location"] = act.get("location", "unknown") - # Replace the namedtuple with the actual ActivityParameter - row["parameter"] = ActivityParameter.get_by_id(parameter.id) - return row - - def get_activity_groups(self, proxy, ignore_groups: list = None) -> Iterable[str]: - """Helper method to look into the Group and determine which if any - other groups the current activity can depend on - """ - db = self.get_key(proxy)[0] - ignore_groups = ignore_groups or [] - return ( - param.group - for param in ( - ActivityParameter.select(ActivityParameter.group) - .where(ActivityParameter.database == db) - .distinct() - ) - if param.group not in ignore_groups - ) - - @staticmethod - def get_usable_parameters(): - """Include all types of parameters. - - NOTE: This method does not take into account which formula is being - edited, and therefore does not restrict which database or activity - parameters are returned. - """ - database = DatabaseParameterModel.get_usable_parameters() - activity = ( - [p.name, p.amount, "activity ({})".format(p.group)] - for p in ActivityParameter.select() - ) - return itertools.chain(database, activity) - - def get_group(self, proxy: QModelIndex = None) -> str: - """Retrieve the group of the activity currently selected.""" - proxy = proxy or self.parent().currentIndex() - idx = self.proxy_to_source(proxy) - return self._dataframe.iat[idx.row(), self.group_col] - - def get_interpreter(self) -> Interpreter: - interpreter = Interpreter() - group = self.get_group(self.parent().currentIndex()) - interpreter.symtable.update(ActivityParameter.static(group, full=True)) - return interpreter - - def get_key(self, proxy: QModelIndex) -> tuple: - index = self.proxy_to_source(proxy) - return self._dataframe.iat[index.row(), self.key_col] - - -class ParameterItem(TreeItem): - @classmethod - def build_header(cls, header: str, parent: TreeItem) -> "ParameterItem": - item = cls([header, "", "", ""], parent) - parent.appendChild(item) - return item - - @classmethod - def build_item(cls, param, parent: TreeItem) -> "ParameterItem": - """Depending on the parameter type, the group is changed, defaults to - 'project'. - - For Activity parameters, use a 'header' item as parent, create one - if it does not exist. - """ - group = "project" - if hasattr(param, "code") and hasattr(param, "database"): - database = "database - {}".format(str(param.database)) - if database not in [x.data(0) for x in parent.children]: - cls.build_header(database, parent) - parent = next(x for x in parent.children if x.data(0) == database) - group = getattr(param, "group") - elif hasattr(param, "database"): - group = param.database - - item = cls( - [ - getattr(param, "name", ""), - group, - getattr( - param, "amount", 1.0 - ), # set to 1 instead of 0 as division by 0 causes problems - getattr(param, "formula", ""), - ], - parent, - ) - - # If the variable is found, we're working on an activity parameter - if "database" in locals(): - cls.build_exchanges(param, item) - - parent.appendChild(item) - return item - - @classmethod - def build_exchanges(cls, act_param, parent: TreeItem) -> None: - """Take the given activity parameter, retrieve the matching activity - and construct tree-items for each exchange with a `formula` field. - """ - act = bd.get_activity((act_param.database, act_param.code)) - - for exc in [exc for exc in act.exchanges() if "formula" in exc]: - try: - act_input = bd.get_activity(exc.input) - item = cls( - [ - act_input.get("name"), - parent.data(1), - exc.amount, - exc.get("formula"), - ], - parent, - ) - parent.appendChild(item) - except DoesNotExist as e: - # The exchange is coming from a deleted database, remove it - logger.warning(f"Broken exchange: {exc}, removing.") - app.actions.ExchangeDelete.run([exc]) - - -class ParameterTreeModel(BaseTreeModel): - """ - Ordering and foldouts as follows: - - Project parameters: - - All 'root' objects - - No children - - Database parameters: - - All 'root' objects - - No children - - Activity parameters: - - Never root objects. - - Placed under simple 'database' root objects - - Exchanges as children - - Exchange parameters: - - Never root objects - - Children of relevant activity parameter - - No children - """ - - HEADERS = ["Name", "Group", "Amount", "Formula"] - - def __init__(self, parent=None): - super().__init__(parent) - self.root = ParameterItem.build_root(self.HEADERS) - self.setup_model_data() - - def setup_model_data(self) -> None: - """First construct the root, then process the data.""" - for param in self._data.get("project", []): - ParameterItem.build_item(param, self.root) - for param in self._data.get("database", []): - ParameterItem.build_item(param, self.root) - for param in self._data.get("activity", []): - try: - _ = bd.get_activity((param.database, param.code)) - except: - continue - ParameterItem.build_item(param, self.root) - - def sync(self, *args, **kwargs) -> None: - self.beginResetModel() - self.root.clear() - self.endResetModel() - self._data.update( - { - "project": ProjectParameter.select().iterator(), - "database": DatabaseParameter.select().iterator(), - "activity": ActivityParameter.select().iterator(), - } - ) - self.setup_model_data() - self.updated.emit() - - -class ScenarioModel(PandasModel): - HEADERS = ["Name", "Group", "default"] - MATCH_COLS = ["Name", "Group"] - - def __init__(self, parent=None): - super().__init__(parent=parent) - signals.project.changed.connect(self.sync) - signals.parameter.changed.connect(self.rebuild_table) - - @Slot(name="doCleanSync") - def sync(self, df: pd.DataFrame = None, include_default: bool = True) -> None: - """Construct the dataframe from the existing parameters, if ``df`` - is given, perform a merge to possibly include additional columns. - """ - data = [p[:3] for p in Parameters.from_bw_parameters()] - if not isinstance(df, pd.DataFrame): - self._dataframe = pd.DataFrame(data, columns=self.HEADERS).set_index("Name") - else: - required = set(self.MATCH_COLS) - if not required.issubset(df.columns): - raise ValueError( - "The given dataframe does not contain required columns: {}".format( - required.difference(df.columns) - ) - ) - assert df.columns.get_loc("Group") == 1 - if isinstance(include_default, bool) and include_default: - new_df = pd.DataFrame(data, columns=self.HEADERS) - if "default" in df.columns: - df.drop(columns="default", inplace=True) - self._dataframe = self._perform_merge(new_df, df).set_index("Name") - else: - # Now we're gonna need to ensure that the dataframe is of - # the same size - assert ( - len(data) >= df.shape[0] - ), "Too many parameters found, not possible." - missing = len(data) - df.shape[0] - if missing != 0: - nan_data = pd.DataFrame( - index=pd.RangeIndex(missing), columns=df.columns - ) - df = pd.concat([df, nan_data], ignore_index=True) - self._dataframe = df.set_index("Name") - self.updated.emit() - - @classmethod - def _perform_merge(cls, left: pd.DataFrame, right: pd.DataFrame) -> pd.DataFrame: - """There are three kinds of actions that can occur: adding new columns, - updating values in matching columns, and a combination of the two. - - ``left`` dataframe always determines the row-size of the resulting - dataframe. - Any `NaN` values in the new columns in ``right`` will be replaced - with values from the `default` column from ``left``. - """ - right_columns = right.drop(columns=cls.MATCH_COLS).columns - matching = right_columns.intersection(left.columns) - if not matching.empty: - # Replace values and drop the matching columns - left[matching] = right[matching] - right.drop(columns=matching, inplace=True) - if right.drop(columns=cls.MATCH_COLS).columns.any(): - # Merge the remaining columns - df = left.merge(right, how="left", on=cls.MATCH_COLS) - else: - df = left - else: - df = left.merge(right, how="left", on=cls.MATCH_COLS) - # Now go over the non-standard columns and see if there are any - # missing values. - new_cols = df.drop(columns=cls.HEADERS).columns - missing = new_cols[df[new_cols].isna().any()] - if not missing.empty: - idx = missing.append(pd.Index(["default"])) - df[idx] = df[idx].apply(lambda x: x.fillna(x["default"]), axis=1) - return df - - @Slot(name="resetDataIndex") - def rebuild_table(self) -> None: - """Should be called when the `parameters_changed` signal is emitted. - Will call sync with a copy of the current dataframe to ensure no - user-imported data is lost. - - TODO: handle database parameter group changes correctly. Maybe a - separate signal like rename? - """ - self.sync(self._dataframe.reset_index()) - - @Slot(str, str, str, name="renameParameterIndex") - def update_param_name(self, old: str, group: str, new: str) -> None: - """Kind of a cheat, but directly edit the dataframe.index to update - the table whenever the user renames a parameter. - """ - new_idx = pd.Index( - np.where( - (self._dataframe.index == old) & (self._dataframe["Group"] == group), - new, - self._dataframe.index, - ), - name=self._dataframe.index.name, - ) - self._dataframe.index = new_idx - self.updated.emit() - - def iterate_scenarios(self) -> Iterable[Tuple[str, Iterable]]: - """Iterates through all of the non-description columns from left to right. - - Returns an iterator of tuples containing the scenario name and a dictionary - of the parameter names and new amounts. - - TODO: Fix this so it returns the least amount of required information. - """ - df = self._dataframe.reset_index() - df = df.set_index(["Group", "Name"]) - - - return ( - (scenario, df[scenario]) - for scenario in df.columns - ) diff --git a/activity_browser/app/pages/parameters/parameter_views.py b/activity_browser/app/pages/parameters/parameter_views.py deleted file mode 100644 index a94f1b7dc..000000000 --- a/activity_browser/app/pages/parameters/parameter_views.py +++ /dev/null @@ -1,284 +0,0 @@ -from asteval import Interpreter -from qtpy.QtCore import Slot -from qtpy.QtGui import QContextMenuEvent, QDragMoveEvent, QDropEvent -from qtpy.QtWidgets import QAction, QMenu - -import bw2data as bd -import bw_functional as bf - -from activity_browser import app, signals -from activity_browser.ui import icons, delegates - -from .parameter_models import ( - BaseParameterModel, - ProjectParameterModel, - DatabaseParameterModel, - ActivityParameterModel, - ParameterTreeModel, - ScenarioModel -) -from .base import ABDataFrameView, ABDictTreeView - - -class ScenarioTable(ABDataFrameView): - """Constructs an infinitely (horizontally) expandable table that is - used to set specific amount for user-defined parameters. - - The two required columns in the dataframe for the table are 'Name', - and 'Type'. all other columns are seen as scenarios containing N floats, - where N is the number of rows found in the Name column. - """ - - def __init__(self, parent=None): - super().__init__(parent) - self.table_name = "scenario_table" - - self.horizontalHeader().setStretchLastSection(False) - self.verticalHeader().setVisible(True) - - self.model = ScenarioModel(self) - self.model.updated.connect(self.update_proxy_model) - signals.project.changed.connect(self.group_column) - - @Slot(bool, name="showGroupColumn") - def group_column(self, shown: bool = False) -> None: - self.setColumnHidden(0, not shown) - - def iterate_scenarios(self) -> list[tuple[str, list]]: - return self.model.iterate_scenarios() - - -class BaseParameterTable(ABDataFrameView): - MODEL = BaseParameterModel - - def __init__(self, parent=None): - super().__init__(parent) - self.setSelectionMode(ABDataFrameView.SingleSelection) - - self.model = self.MODEL(self) - self.doubleClicked.connect( - lambda: self.model.handle_double_click(self.currentIndex()) - ) - self.delete_action = QAction(icons.qicons.delete, "Delete parameter", None) - self.delete_action.triggered.connect( - lambda: self.model.delete_parameter(self.currentIndex()) - ) - self.rename_action = QAction(icons.qicons.edit, "Rename parameter", None) - self.rename_action.triggered.connect( - lambda: self.model.handle_parameter_rename(self.currentIndex()) - ) - self.modify_uncertainty_action = QAction( - icons.qicons.edit, "Modify uncertainty", None - ) - self.modify_uncertainty_action.triggered.connect(self.modify_uncertainty) - self.remove_uncertainty_action = QAction( - icons.qicons.delete, "Remove uncertainty", None - ) - self.remove_uncertainty_action.triggered.connect(self.remove_uncertainty) - self.model.updated.connect(self.update_proxy_model) - - # hide raw parameter column - self.model.updated.connect( - lambda: self.setColumnHidden(self.model.param_col, True) - ) - self.model.updated.connect(lambda: self.resizeColumnToContents(0)) - - def contextMenuEvent(self, event: QContextMenuEvent) -> None: - """Have the parameter test to see if it can be deleted safely.""" - if self.indexAt(event.pos()).row() == -1: - return - menu = QMenu(self) - menu.addAction(self.rename_action) - menu.addAction(self.modify_uncertainty_action) - menu.addSeparator() - menu.addAction(self.delete_action) - menu.addAction(self.remove_uncertainty_action) - proxy = self.indexAt(event.pos()) - if proxy.isValid(): - param = self.get_parameter(proxy) - if param.is_deletable(): - self.delete_action.setEnabled(True) - else: - self.delete_action.setEnabled(False) - menu.exec_(event.globalPos()) - - def get_parameter(self, proxy): - return self.model.get_parameter(proxy) - - def get_key(self, *args) -> tuple: - return self.model.get_key() - - def delete_parameter(self, proxy) -> None: - self.model.delete_parameter(proxy) - - @Slot(name="modifyParameterUncertainty") - def modify_uncertainty(self) -> None: - proxy = next(p for p in self.selectedIndexes()) - self.model.modify_uncertainty(proxy) - - @Slot(name="unsetParameterUncertainty") - def remove_uncertainty(self) -> None: - proxy = next(p for p in self.selectedIndexes()) - self.model.remove_uncertainty(proxy) - - def comment_column(self, show: bool): - self.setColumnHidden(self.model.comment_col, not show) - self.resizeColumnsToContents() - self.resizeRowsToContents() - - -class ProjectParameterTable(BaseParameterTable): - MODEL = ProjectParameterModel - - def __init__(self, parent=None): - super().__init__(parent) - self.table_name = "project_parameter" - - # Set delegates for specific columns - self.setItemDelegateForColumn(1, delegates.FloatDelegate(self)) - self.setItemDelegateForColumn(3, delegates.StringDelegate(self)) - self.setItemDelegateForColumn(4, delegates.ViewOnlyUncertaintyDelegate(self)) - - def uncertainty_columns(self, show: bool): - for i in range(4, 10): - self.setColumnHidden(i, not show) - - @staticmethod - def get_usable_parameters(): - return ProjectParameterModel.get_usable_parameters() - - @staticmethod - def get_interpreter() -> Interpreter: - return ProjectParameterModel.get_interpreter() - - -class DataBaseParameterTable(BaseParameterTable): - MODEL = DatabaseParameterModel - - def __init__(self, parent=None): - super().__init__(parent) - self.table_name = "database_parameter" - - # Set delegates for specific columns - self.setItemDelegateForColumn(1, delegates.FloatDelegate(self)) - self.setItemDelegateForColumn(3, delegates.DatabaseDelegate(self)) - self.setItemDelegateForColumn(4, delegates.StringDelegate(self)) - self.setItemDelegateForColumn(5, delegates.ViewOnlyUncertaintyDelegate(self)) - - def uncertainty_columns(self, show: bool): - for i in range(5, 11): - self.setColumnHidden(i, not show) - - def get_key(self) -> tuple: - return self.model.get_key(self.currentIndex()) - - @staticmethod - def get_usable_parameters(): - return DatabaseParameterModel.get_usable_parameters() - - def get_interpreter(self) -> Interpreter: - """Take the interpreter from the ProjectParameterTable and add - (potentially overwriting) all database symbols for the selected index. - """ - return self.model.get_interpreter() - - -class ActivityParameterTable(BaseParameterTable): - MODEL = ActivityParameterModel - - def __init__(self, parent=None): - super().__init__(parent) - self.table_name = "activity_parameter" - - # Set delegates for specific columns - self.setItemDelegateForColumn(1, delegates.FloatDelegate(self)) - self.setItemDelegateForColumn(6, delegates.StringDelegate(self)) - self.setItemDelegateForColumn(7, delegates.ListDelegate(self)) - self.setItemDelegateForColumn(9, delegates.StringDelegate(self)) - self.setItemDelegateForColumn(10, delegates.ViewOnlyUncertaintyDelegate(self)) - - # Set dropEnabled - self.setDragDropMode(ABDataFrameView.DragDropMode.DropOnly) - self.setAcceptDrops(True) - - def dragMoveEvent(self, event, /): - pass - - def dragEnterEvent(self, event: QDragMoveEvent) -> None: - """Check that the dragged row is from the databases table""" - if event.mimeData().hasFormat("application/bw-nodekeylist"): - event.accept() - - def dropEvent(self, event: QDropEvent) -> None: - """If the user drops an activity into the activity parameters table - read the relevant data from the database and generate a new row. - - Also, create a warning if the activity is from a read-only database - """ - keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - processes = set() - - for key in keys: - act = bd.get_node(key=key) - if isinstance(act, bf.Product): - continue - processes.add(key) - event.accept() - app.actions.ParameterNewAutomatic.run(processes) - - def contextMenuEvent(self, event: QContextMenuEvent) -> None: - """Override and activate QTableView.contextMenuEvent() - - All possible menu events should be added and wired up here - """ - if self.indexAt(event.pos()).row() == -1: - return - menu = QMenu(self) - menu.addAction(icons.qicons.add, "Open activities", self.open_activity_tab) - menu.addAction(self.rename_action) - menu.addAction(self.delete_action) - menu.addAction(self.modify_uncertainty_action) - proxy = self.indexAt(event.pos()) - if proxy.isValid(): - param = self.get_parameter(proxy) - if param.is_deletable(): - self.delete_action.setEnabled(True) - else: - self.delete_action.setEnabled(False) - menu.exec_(event.globalPos()) - - @Slot() - def open_activity_tab(self): - """Triggers the activity tab to open one or more activities.""" - for proxy in self.selectedIndexes(): - key = self.get_key(proxy) - signals.safe_open_activity_tab.emit(key) - - def uncertainty_columns(self, show: bool): - for i in range(10, 16): - self.setColumnHidden(i, not show) - - def get_key(self, proxy=None) -> tuple: - proxy = proxy or self.currentIndex() - return self.model.get_key(proxy) - - def get_activity_groups(self, proxy, ignore_groups: list = None): - return self.model.get_activity_groups(proxy, ignore_groups) - - @staticmethod - def get_usable_parameters(): - return ActivityParameterModel.get_usable_parameters() - - def get_current_group(self, proxy=None) -> str: - """Retrieve the group of the activity currently selected.""" - return self.model.get_group(proxy or self.currentIndex()) - - def get_interpreter(self) -> Interpreter: - return self.model.get_interpreter() - - -class ExchangesTable(ABDictTreeView): - def __init__(self, parent=None): - super().__init__(parent) - self.model = ParameterTreeModel(parent=self) - self.setModel(self.model) diff --git a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py new file mode 100644 index 000000000..08c2ddc92 --- /dev/null +++ b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py @@ -0,0 +1,275 @@ +from qtpy import QtWidgets, QtCore +from qtpy.QtCore import Qt +import pandas as pd +import bw2data as bd +from bw2data.parameters import ParameterizedExchange +from bw2data.backends import ExchangeDataset + +from activity_browser import app +from activity_browser.ui import widgets, icons, delegates, core +from activity_browser.bwutils.commontasks import database_is_locked +from activity_browser.bwutils.utils import Parameter + + +class ParameterizedExchangesSection(QtWidgets.QWidget): + """ + A widget section that displays all parameterized exchanges in the current project. + + Attributes: + model (ParameterizedExchangesModel): The model containing the data for the exchanges. + view (ParameterizedExchangesView): The view displaying the exchanges. + """ + + def __init__(self, parent=None): + """ + Initializes the ParameterizedExchangesSection widget. + + Args: + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + + # Parameterized exchanges table view + self.model = ParameterizedExchangesModel(parent=self) + self.view = ParameterizedExchangesView() + self.view.setModel(self.model) + + self.build_layout() + self.connect_signals() + + def build_layout(self): + """ + Builds the layout of the widget. + """ + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.view) + self.setLayout(layout) + + def connect_signals(self): + """ + Connects signals to their respective slots. + """ + app.signals.metadata.synced.connect(self.sync) + app.signals.parameter.changed.connect(self.sync) + app.signals.parameter.recalculated.connect(self.sync) + app.signals.parameter.deleted.connect(self.sync) + app.signals.project.changed.connect(self.sync) + app.signals.meta.databases_changed.connect(self.sync) + + def sync(self): + """ + Synchronizes the widget with the current state of parameterized exchanges. + """ + df = self.build_exchanges_df() + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) + + def build_exchanges_df(self) -> pd.DataFrame: + """ + Builds a DataFrame from all parameterized exchanges in the project. + + Returns: + pd.DataFrame: The DataFrame containing the parameterized exchanges data. + """ + translated = [] + + # Get all parameterized exchanges + for param_exc in ParameterizedExchange.select(): + try: + exchange = bd.Edge(document=ExchangeDataset.get_by_id(param_exc.exchange)) + + # Get keys for input and output + input_key = exchange.get("input") + output_key = exchange.get("output") + + # Get metadata from metadata store + input_meta = app.metadata.get_metadata([input_key], ["name", "unit", "location", "database", "product"]).iloc[0] + output_meta = app.metadata.get_metadata([output_key], ["name"]).iloc[0] + + row = { + "amount": exchange.get("amount"), + "unit": input_meta.get("unit"), + "from": input_meta.get("product") or input_meta.get("name"), + "to": output_meta.get("name"), + "database": input_meta.get("database"), + "formula": exchange.get("formula"), + "comment": exchange.get("comment"), + "uncertainty": exchange.get("uncertainty type"), + "_exchange": exchange, + "_output_key": output_key, + "_input_key": input_key, + } + translated.append(row) + except Exception as e: + # Skip if exchange can't be loaded + continue + + columns = ["amount", "unit", "from", "to", "database", "formula", "comment", "uncertainty", "_exchange", "_output_key", "_input_key"] + return pd.DataFrame(translated, columns=columns) + + +class ParameterizedExchangesView(widgets.ABTreeView): + """ + A view that displays parameterized exchanges in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + """ + defaultColumnDelegates = { + "amount": delegates.FloatDelegate, + "unit": delegates.StringDelegate, + "product": delegates.StringDelegate, + "producer": delegates.StringDelegate, + "location": delegates.StringDelegate, + "database": delegates.StringDelegate, + "formula": delegates.NewFormulaDelegate, + "comment": delegates.StringDelegate, + "uncertainty": delegates.UncertaintyDelegate, + } + + class ContextMenu(widgets.ABMenu): + """ + A context menu for the ParameterizedExchangesView. + """ + def __init__(self, pos, view: "ParameterizedExchangesView"): + """ + Initializes the ContextMenu. + + Args: + pos: The position of the context menu. + view (ParameterizedExchangesView): The view displaying the exchanges. + """ + super().__init__(view) + + index = view.indexAt(pos) + if index.isValid() and not view.model().isBranchNode(index): + row = view.model().row(index) + if row is not None: + output_key = row.get("_output_key") + if output_key: + # Open activity action + open_action = app.actions.ActivityOpen.get_QAction([output_key]) + open_action.setText("Open activity") + self.addAction(open_action) + + +class ParameterizedExchangesModel(core.ABTreeModel): + """ + A model representing the data for parameterized exchanges. + """ + + def __init__(self, parent=None): + """ + Initializes the ParameterizedExchangesModel. + + Args: + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(df=pd.DataFrame(), parent=parent) + + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: + """ + Sets the data for the given index. + + Args: + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. + + Returns: + bool: True if the data was set successfully, False otherwise. + """ + if role != Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False + + exchange = row.get("_exchange") + if exchange is None: + return False + + if column_name in ["amount", "formula", "comment"]: + if column_name == "formula" and not str(value).strip(): + # Remove formula if empty + app.actions.ExchangeFormulaRemove.run([exchange]) + return True + + app.actions.ExchangeModify.run(exchange, {column_name.lower(): value}) + return True + + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: + """ + Provides decoration data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide decoration data. + + Returns: + The decoration data for the index. + """ + column_name = self.column_name(index) + + if column_name == "amount": + formula = self.get(index, "formula") + if pd.isna(formula) or formula is None or formula == "": + return icons.qicons.edit + return icons.qicons.parameterized + + return None + + def indexEditable(self, index: QtCore.QModelIndex) -> bool: + """ + Returns whether the index is editable. + + Args: + index (QtCore.QModelIndex): The index to check. + + Returns: + bool: True if the index is editable, False otherwise. + """ + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False + + # Check if database is locked + exchange = row.get("_exchange") + if exchange and database_is_locked(exchange.output["database"]): + return False + + # Allow editing for specific columns + if column_name in ["amount", "formula", "comment"]: + return True + + return False + + def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: + """ + Returns the parameters in scope of the exchange at the given index. + + Args: + index (QtCore.QModelIndex): The index to get scoped parameters for. + + Returns: + dict: The parameters in scope. + """ + from activity_browser.bwutils.commontasks import parameters_in_scope + + row = self.row(index) + if row is None: + return {} + + exchange = row.get("_exchange") + if exchange is None: + return {} + + return parameters_in_scope(node=exchange.output) + diff --git a/activity_browser/app/pages/parameters/parameters.py b/activity_browser/app/pages/parameters/parameters.py index c9ef3421b..72c953230 100644 --- a/activity_browser/app/pages/parameters/parameters.py +++ b/activity_browser/app/pages/parameters/parameters.py @@ -1,495 +1,65 @@ -from pathlib import Path - -from xlsxwriter.exceptions import FileCreateError - -import pandas as pd -import bw2data as bd - from qtpy import QtWidgets, QtCore -from qtpy.QtCore import Qt - -from activity_browser import app, signals -from activity_browser.ui import icons, widgets -from activity_browser.bwutils import manager, superstructure -from .parameter_views import ActivityParameterTable, BaseParameterTable, DataBaseParameterTable, ExchangesTable, ProjectParameterTable, ScenarioTable +from activity_browser.ui import widgets +from .parameters_section import ParametersSection +from .parameterized_exchanges_section import ParameterizedExchangesSection -class ParametersPage(QtWidgets.QTabWidget): - """Parameters tab in which user can define project-, database- and - activity-level parameters for their system. - Changing projects will trigger a reload of all parameters +class ParametersPage(QtWidgets.QWidget): """ + A widget that displays all parameters and parameterized exchanges in the current project. - def __init__(self, parent=None): - super().__init__(parent) - self.setTabsClosable(False) - - # Initialize both parameter tabs - self.tabs = { - "Definitions": ParameterDefinitionTab(self), - "Exchanges": ParameterExchangesTab(self), - "Scenarios": ParameterScenariosTab(self), - } - for name, tab in self.tabs.items(): - self.addTab(tab, name) - - for tab in self.tabs.values(): - if hasattr(tab, "build_tables"): - tab.build_tables() - - self._connect_signals() - - def _connect_signals(self): - # signals.add_activity_parameter.connect(self.activity_parameter_added) - pass - - def activity_parameter_added(self) -> None: - """Selects the correct sub-tab to show and trigger a switch to - the Parameters tab. - """ - self.setCurrentIndex(self.indexOf(self.tabs["Definitions"])) - signals.show_tab.emit("Parameters") - - -class ABParameterTable(QtWidgets.QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.table = None - self.header = None - - def create_layout( - self, - title: str = None, - bttn: QtWidgets.QAbstractButton = None, - table: BaseParameterTable = None, - ): - headerLayout = QtWidgets.QHBoxLayout() - self.header = widgets.ABLabel.demiBold(title) - - headerLayout.addWidget(self.header) - headerLayout.addWidget(bttn) - headerLayout.addStretch(1) - - layout = QtWidgets.QVBoxLayout() - layout.addLayout(headerLayout) - layout.addWidget(table) - return layout - - def get_table(self): - return self.table - - -class ABProjectParameter(ABParameterTable): - def __init__(self, parent=None): - super().__init__(parent) - self.new_parameter_button = app.actions.ParameterNew.get_QButton(("", "")) - self.header = "Project:" - self.table = ProjectParameterTable(self) - - self.setLayout( - self.create_layout(self.header, self.new_parameter_button, self.table) - ) - - -class ABDatabaseParameter(ABParameterTable): - def __init__(self, parent=None): - super().__init__(parent) - self.header = "Database:" - - self.new_parameter_button = app.actions.ParameterNew.get_QButton(("db", "")) - - self.table = DataBaseParameterTable(self) - - self.setLayout( - self.create_layout(self.header, self.new_parameter_button, self.table) - ) - - def set_enabled(self, trigger): - if not list(bd.databases): - self.new_parameter_button.setEnabled(False) - else: - self.new_parameter_button.setEnabled(True) - - -class ABActivityParameter(ABParameterTable): - def __init__(self, parent=None): - super().__init__(parent) - self.header = "Activity:" - self.parameter = QtWidgets.QCheckBox("Show order column", self) - self.table = ActivityParameterTable(self) - - self.setLayout(self.create_layout(self.header, self.parameter, self.table)) - self._connect_signal() - - def _connect_signal(self): - self.parameter.stateChanged.connect(self.activity_order_column) - - def activity_order_column(self) -> None: - col = self.table.model.order_col - state = self.parameter.isChecked() - if not state: - self.table.setColumnHidden(col, True) - else: - self.table.setColumnHidden(col, False) - self.table.resizeColumnToContents(col) - - -class ParameterDefinitionTab(QtWidgets.QWidget): - """Parameter definitions tab. - - This tab shows three tables containing the project-, database- and - activity level parameters set for the project. - - The user can create new parameters at these three levels and save - new or edited parameters with a single button. - Pressing the save button will cause brightway to validate the changes - and a warning message will appear if an error occurs. - """ - - def __init__(self, parent=None): - super().__init__(parent) - - self.project_table = ABProjectParameter(self) - self.database_table = ABDatabaseParameter(self) - self.activity_table = ABActivityParameter(self) - self.tables = { - "project": self.project_table.get_table(), - "database": self.database_table.get_table(), - "activity": self.activity_table.get_table(), - } - for t in self.tables.values(): - t.model.sync() - - self.show_database_params = QtWidgets.QCheckBox("Database parameters", self) - self.show_database_params.setToolTip("Show/hide the database parameters") - self.show_database_params.setChecked(True) - - self.show_activity_params = QtWidgets.QCheckBox("Activity parameters", self) - self.show_activity_params.setToolTip("Show/hide the activity parameters") - self.show_activity_params.setChecked(True) - self.comment_column = QtWidgets.QCheckBox("Comments", self) - self.comment_column.setToolTip("Show/hide the comment column") - self.hide_comment_column() - self.uncertainty_columns = QtWidgets.QCheckBox("Uncertainty", self) - self.uncertainty_columns.setToolTip("Show/hide the uncertainty columns") - - self._construct_layout() - self._connect_signals() - - self.explain_text = """ -

This tab is the main tab for creating and modifying parameters.

-

The scope of parameters can be either a specific activity, a database, or an entire project -(meaning that an activity parameter can only be used within a specific activity, -while a project parameter can be used anywhere within a project and across all databases within that project).

- - - -

In general

-

All parameters must have a name and amount. A formula is optional.

-

The formula is stored as a string that is interpreted by brightway. Python builtin functions and Numpy functions -can be used within the formula!

-

Parameters can only be deleted if they are not used in formulas of other parameters.

-

Note that optionally uncertainties, can be specified for parameters.

- -

Activity parameters

-

New parameters are added either by drag-and-dropping activities from the database table or by adding - a formula to an activity exchange within the Activity tab.

-
    -
  • Only activities from editable databases can be parameterized.
  • -
  • Multiple parameters can be created for a single activity.
  • -
  • The parameter name must be unique within the group of parameters for an activity.
  • -
  • Note: activity parameters are also auto-generated when a project or database parameter is used in an activity that has previously not been parameterized.
  • -
- - - -

For more information on this topic see also the -Brightway2 documentation.

-""" - - def _connect_signals(self): - signals.project.changed.connect(self.build_tables) - signals.parameter.recalculated.connect(self.build_tables) - - self.show_database_params.toggled.connect(self.hide_database_parameter) - self.show_activity_params.toggled.connect(self.hide_activity_parameter) - self.comment_column.stateChanged.connect(self.hide_comment_column) - self.uncertainty_columns.stateChanged.connect(self.hide_uncertainty_columns) - - def _construct_layout(self): - """Construct the widget layout for the variable parameters tab""" - layout = QtWidgets.QVBoxLayout() - - self.uncertainty_columns.setChecked(False) - row = QtWidgets.QToolBar() - _header = widgets.ABLabel.demiBold("Parameters ") - _header.setToolTip("Left click on the question mark for help") - row.addWidget(_header) - row.addWidget(self.show_database_params) - row.addWidget(self.show_activity_params) - row.addWidget(self.comment_column) - row.addWidget(self.uncertainty_columns) - layout.addWidget(row) - layout.addWidget(widgets.ABHLine(self)) - - tables = QtWidgets.QSplitter(Qt.Vertical) - tables.addWidget(self.project_table) - tables.addWidget(self.database_table) - tables.addWidget(self.activity_table) - layout.addWidget(tables) - - self.setLayout(layout) - - def build_tables(self): - """Read parameters from brightway and build dataframe tables""" - self.hide_uncertainty_columns() - self.activity_order_column() - # Cannot create database parameters without databases - if not list(bd.databases): - self.database_table.set_enabled(False) - else: - self.database_table.set_enabled(True) - - def hide_uncertainty_columns(self): - show = self.uncertainty_columns.isChecked() - for table in self.tables.values(): - table.uncertainty_columns(show) - - def hide_comment_column(self): - show = self.comment_column.isChecked() - for table in self.tables.values(): - table.comment_column(show) - - def activity_order_column(self) -> None: - col = self.activity_table.get_table().model.order_col - state = self.activity_table.parameter.isChecked() - if not state: - self.activity_table.get_table().setColumnHidden(col, True) - else: - self.activity_table.get_table().setColumnHidden(col, False) - self.activity_table.get_table().resizeColumnToContents(col) - - def hide_database_parameter(self, toggled: bool) -> None: - self.database_table.header.setHidden(not toggled) - self.database_table.new_parameter_button.setHidden(not toggled) - self.database_table.table.setHidden(not toggled) - self.database_table.setHidden(not toggled) - - def hide_activity_parameter(self, toggled: bool) -> None: - self.activity_table.header.setHidden(not toggled) - self.activity_table.parameter.setHidden(not toggled) - self.activity_table.table.setHidden(not toggled) - self.activity_table.setHidden(not toggled) - - -class ParameterExchangesTab(QtWidgets.QWidget): - """Overview of exchanges - - This tab shows a foldable treeview table containing all of the - parameters set for the current project. - - Changes made to parameters in the `Definitions` tab will require - the user to press `Recalculate exchanges` to ensure the amounts in - the exchanges are properly updated. + This page shows: + - Parameters section: A tree view of parameters organized by scope + - Parameterized exchanges section: A table of exchanges with formulas """ def __init__(self, parent=None): - super().__init__(parent) - - self.table = ExchangesTable(self) - - self._construct_layout() - self._connect_signals() - - self.explain_text = """ -

This tab lists all exchanges within the selected project that are calculated via parameters.

-

The Project level parameters are shown above the database and activity parameters.

-

To see the different database and activity parameters in the Project click on the arrows to expand the trees

- -

For more information on this topic see also the -Brightway2 documentation.

-""" - - def _connect_signals(self): - signals.project.changed.connect(self.build_tables) - signals.parameter.recalculated.connect(self.build_tables) - - def _construct_layout(self): - """Construct the widget layout for the exchanges parameters tab""" - layout = QtWidgets.QVBoxLayout() - row = QtWidgets.QToolBar() - _header = widgets.ABLabel.demiBold("Overview of parameterized exchanges") - _header.setToolTip("Left click on the question mark for help") - row.addWidget(_header) - row.setIconSize(QtCore.QSize(24, 24)) - layout.addWidget(row) - layout.addWidget(widgets.ABHLine(self)) - layout.addWidget(self.table, 2) - self.setLayout(layout) - - def build_tables(self) -> None: - """Read parameters from brightway and build tree tables""" - self.table.model.sync() - + """ + Initializes the ParametersPage widget. -class ParameterScenariosTab(QtWidgets.QWidget): - def __init__(self, parent=None): + Args: + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ super().__init__(parent) - self.load_btn = QtWidgets.QPushButton(icons.qicons.add, "Import parameter-scenarios") - self.load_btn.setToolTip( - "Load prepared excel files with additional parameter scenarios." - ) - self.save_btn = QtWidgets.QPushButton( - self.style().standardIcon(QtWidgets.QStyle.SP_DialogSaveButton), - "Export parameter-scenarios", - ) - self.save_btn.setToolTip( - "Export the current parameter scenario table to excel." - ) - self.calculate_btn = QtWidgets.QPushButton(icons.qicons.calculate, "Export as flow-scenarios") - self.calculate_btn.setToolTip( - ( - "Process the current parameter scenario table into prepared flow" - " scenario data." - ) - ) - self.reset_btn = QtWidgets.QPushButton(icons.qicons.history, "Reset table") - self.reset_btn.setToolTip("Reset the scenario table, wiping any changes.") - self.hide_group = QtWidgets.QCheckBox("Show group column") - - self.tbl = ScenarioTable(self) - self.tbl.setToolTip( - "This table is not editable, use the export/import functionality" - ) + self.parameters_section = ParametersSection(self) + self.parameterized_exchanges_section = ParameterizedExchangesSection(self) - self._construct_layout() - self._connect_signals() + self.build_layout() - self.explain_text = """ -

This tab has 3 functions:

-

1. Export parameter-scenarios : this exports the table as shown below to an Excel file. You can modify it there and use - it in scenario LCAs (see Calculation Setup tab)

-

2. Import parameter-scenarios: imports a table like the one shown below from Excel. If parameters are missing in Excel, - the default values will be used. IMPORTANT NOTE: the ONLY function this button serves is to display the Excel file. - If you want to use the Excel file in scenario LCA, please import it in the Calculation Setup tab.

-

3. Export as flow-scenarios: This converts a "parameter-scenarios" file (alternative values for parameters) to a - "flow-scenarios" file (alternative values for the exchanges as used in LCA calculations).

- -

Suggested workflow to create scenarios for your parameters:

-

Export parameter-scenarios. This will generate an Excel file for you where you can add scenarios (columns). - You may want to delete rows that you intend to change or rows that are for dependent parameters (those that depend on other parameters) as these values will be overwritten by the formulas. - Finally, import the parameter-scenarios in the Calculation Setup (not here!) to perform scenario calculations (you need to select "Scenario LCA").

- -

For more information on this topic see also the - Brightway2 documentation.

- """ - - def _connect_signals(self): - self.load_btn.clicked.connect(self.select_read_file) - self.save_btn.clicked.connect(self.save_scenarios) - self.calculate_btn.clicked.connect(self.calculate_scenarios) - self.reset_btn.clicked.connect(self.tbl.model.sync) - self.hide_group.toggled.connect(self.tbl.group_column) - signals.parameter_scenario_sync.connect(self.process_scenarios) - - def _construct_layout(self): - layout = QtWidgets.QVBoxLayout() - - row = QtWidgets.QToolBar() - _header = widgets.ABLabel.demiBold("Parameter Scenarios") - _header.setToolTip("Click on the question mark for help") - row.addWidget(_header) - layout.addWidget(row) - layout.addWidget(widgets.ABHLine(self)) - - row = QtWidgets.QHBoxLayout() - # row.addWidget(self.reset_btn) - row.addWidget(self.save_btn) - # row.addWidget(self.load_btn) - row.addWidget(self.calculate_btn) - # row.addWidget(self.hide_group) - row.addStretch(1) - layout.addLayout(row) - layout.addWidget(self.tbl) - self.setLayout(layout) - - def process_scenarios( - self, table_idx: int, df: pd.DataFrame, default: bool - ) -> None: - """Use this method to discretely process a parameter scenario file - for the LCA setup. + def build_layout(self): """ - try: - self.tbl.model.sync(df=df, include_default=default) - scenarios = self.build_flow_scenarios() - signals.parameter_superstructure_built.emit(table_idx, scenarios) - except AssertionError as e: - QtWidgets.QMessageBox.critical( - self, "Cannot load parameters", str(e), QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.Ok - ) - - def select_read_file(self): - path, _ = QtWidgets.QFileDialog.getOpenFileName( - self, caption="Select prepared scenario file", filter=self.tbl.EXCEL_FILTER - ) - if path: - df = pd.read_excel(path, engine="openpyxl") - self.tbl.model.sync(df=df) - - def save_scenarios(self): - try: - self.tbl.to_excel("Save current scenarios to Excel") - except FileCreateError as e: - QtWidgets.QMessageBox.warning( - self, - "File save error", - "Cannot save the file, please see if it is opened elsewhere or " - "if you are allowed to save files in that location:\n\n{}".format(e), - QtWidgets.QMessageBox.Ok, - QtWidgets.QMessageBox.Ok, - ) - - def calculate_scenarios(self): - df = self.build_flow_scenarios() - self.store_flows_to_file(df) - - def build_flow_scenarios(self) -> pd.DataFrame: - """Calculate exchange changes for each parameter scenario and construct - a flow scenarios template file. + Builds the layout of the widget. """ - pm = manager.ParameterManager() - names, data = zip(*self.tbl.iterate_scenarios()) + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 3, 0, 0) + + # Add both sections in a splitter + splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical, self) + + # Parameters section + params_widget = QtWidgets.QWidget() + params_layout = QtWidgets.QVBoxLayout(params_widget) + params_layout.setContentsMargins(0, 0, 0, 0) + params_label = widgets.ABLabel.demiBold(" Parameters") + params_layout.addWidget(params_label) + params_layout.addWidget(widgets.ABHLine(self)) + params_layout.addWidget(self.parameters_section) + splitter.addWidget(params_widget) + + # Parameterized exchanges section + exchanges_widget = QtWidgets.QWidget() + exchanges_layout = QtWidgets.QVBoxLayout(exchanges_widget) + exchanges_layout.setContentsMargins(0, 0, 0, 0) + exchanges_label = widgets.ABLabel.demiBold(" Parameterized Exchanges") + exchanges_layout.addWidget(exchanges_label) + exchanges_layout.addWidget(widgets.ABHLine(self)) + exchanges_layout.addWidget(self.parameterized_exchanges_section) + splitter.addWidget(exchanges_widget) + + layout.addWidget(splitter) + self.setLayout(layout) - exchanges = pm.exchanges_from_scenarios(names, data) - df = superstructure.superstructure_from_scenario_exchanges(exchanges) - return df - def store_flows_to_file(self, df: pd.DataFrame) -> None: - filename, _ = QtWidgets.QFileDialog.getSaveFileName( - self, - caption="Save calculated flow scenarios to Excel", - filter=self.tbl.EXCEL_FILTER, - ) - if filename: - try: - path = Path(filename) - path = ( - path - if path.suffix in {".xlsx", ".xls"} - else path.with_suffix(".xlsx") - ) - df.to_excel(excel_writer=path, index=False) - except FileCreateError as e: - QtWidgets.QMessageBox.warning( - self, - "File save error", - "Cannot save the file, please see if it is opened elsewhere or " - "if you are allowed to save files in that location:\n\n{}".format( - e - ), - QtWidgets.QMessageBox.Ok, - QtWidgets.QMessageBox.Ok, - ) diff --git a/activity_browser/app/pages/parameters/parameters_new.py b/activity_browser/app/pages/parameters/parameters_section.py similarity index 58% rename from activity_browser/app/pages/parameters/parameters_new.py rename to activity_browser/app/pages/parameters/parameters_section.py index c0b11673a..273be0c71 100644 --- a/activity_browser/app/pages/parameters/parameters_new.py +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -2,8 +2,7 @@ from qtpy.QtCore import Qt import pandas as pd import bw2data as bd -from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, ParameterizedExchange -from bw2data.backends import ExchangeDataset +from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter from activity_browser import app from activity_browser.ui import widgets, icons, delegates, core @@ -11,11 +10,11 @@ from activity_browser.bwutils.utils import Parameter -class ParametersPage(QtWidgets.QWidget): +class ParametersSection(QtWidgets.QWidget): """ - A widget that displays all parameters in the current project. + A widget section that displays all parameters in the current project. - This page shows a tree view of parameters organized by scope: + This section shows a tree view of parameters organized by scope: - Project parameters - Database parameters (grouped by database) - Activity parameters (grouped by activity group) @@ -27,7 +26,7 @@ class ParametersPage(QtWidgets.QWidget): def __init__(self, parent=None): """ - Initializes the ParametersPage widget. + Initializes the ParametersSection widget. Args: parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. @@ -39,11 +38,6 @@ def __init__(self, parent=None): self.view = ProjectParametersView() self.view.setModel(self.model) - # Parameterized exchanges table view - self.exchanges_model = ParameterizedExchangesModel(parent=self) - self.exchanges_view = ParameterizedExchangesView() - self.exchanges_view.setModel(self.exchanges_model) - self.build_layout() self.connect_signals() @@ -52,36 +46,8 @@ def build_layout(self): Builds the layout of the widget. """ layout = QtWidgets.QVBoxLayout() - - # Header with title for parameters - header_layout = QtWidgets.QHBoxLayout() - header_label = widgets.ABLabel.demiBold("Parameters") - header_layout.addWidget(header_label) - header_layout.addStretch(1) - - layout.addLayout(header_layout) - layout.addWidget(widgets.ABHLine(self)) - - # Add both views in a splitter - splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical, self) - - # Parameters tree - params_widget = QtWidgets.QWidget() - params_layout = QtWidgets.QVBoxLayout(params_widget) - params_layout.setContentsMargins(0, 0, 0, 0) - params_layout.addWidget(self.view) - splitter.addWidget(params_widget) - - # Parameterized exchanges - exchanges_widget = QtWidgets.QWidget() - exchanges_layout = QtWidgets.QVBoxLayout(exchanges_widget) - exchanges_layout.setContentsMargins(0, 0, 0, 0) - exchanges_label = widgets.ABLabel.demiBold("Parameterized Exchanges") - exchanges_layout.addWidget(exchanges_label) - exchanges_layout.addWidget(self.exchanges_view) - splitter.addWidget(exchanges_widget) - - layout.addWidget(splitter) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.view) self.setLayout(layout) def connect_signals(self): @@ -104,13 +70,7 @@ def sync(self): self.model.set_dataframe(df) self.model.group(["_param_type", "_scope"]) self.view.expandAll() - - exchanges_df = self.build_exchanges_df() - exchanges_df.reset_index(drop=True, inplace=True) - self.exchanges_model.set_dataframe(exchanges_df) - self.view.expandAll() - self.view.resizeColumnToContents(1) self.view.resizeColumnToContents(3) self.view.resizeColumnToContents(4) @@ -187,7 +147,7 @@ def build_df(self) -> pd.DataFrame: new_df = pd.DataFrame(new_rows) df = pd.concat([df, new_df], ignore_index=True) - return df.sort_values(by="_param_type", key= lambda c: c.map({"project": 0, "database": 1, "activity": 2})) + return df.sort_values(by="_param_type", key=lambda c: c.map({"project": 0, "database": 1, "activity": 2})) def _parameter_to_row(self, param, scope_label: str = None, database: str = None) -> dict: """ @@ -234,49 +194,6 @@ def _parameter_to_row(self, param, scope_label: str = None, database: str = None return row - def build_exchanges_df(self) -> pd.DataFrame: - """ - Builds a DataFrame from all parameterized exchanges in the project. - - Returns: - pd.DataFrame: The DataFrame containing the parameterized exchanges data. - """ - translated = [] - - # Get all parameterized exchanges - for param_exc in ParameterizedExchange.select(): - try: - exchange = bd.Edge(document=ExchangeDataset.get_by_id(param_exc.exchange)) - - # Get keys for input and output - input_key = exchange.get("input") - output_key = exchange.get("output") - - # Get metadata from metadata store - input_meta = app.metadata.get_metadata([input_key], ["name", "unit", "location", "database", "product"]).iloc[0] - output_meta = app.metadata.get_metadata([output_key], ["name"]).iloc[0] - - row = { - "amount": exchange.get("amount"), - "unit": input_meta.get("unit"), - "from": input_meta.get("product") or input_meta.get("name"), - "to": output_meta.get("name"), - "database": input_meta.get("database"), - "formula": exchange.get("formula"), - "comment": exchange.get("comment"), - "uncertainty": exchange.get("uncertainty type"), - "_exchange": exchange, - "_output_key": output_key, - "_input_key": input_key, - } - translated.append(row) - except Exception as e: - # Skip if exchange can't be loaded - continue - - columns = ["amount", "unit", "from", "to", "database", "formula", "comment", "uncertainty", "_exchange", "_output_key", "_input_key"] - return pd.DataFrame(translated, columns=columns) - class ProjectParametersView(widgets.ABTreeView): """ @@ -441,7 +358,7 @@ def indexEditable(self, index: QtCore.QModelIndex) -> bool: # Allow editing for specific columns if column_name in ["formula", "uncertainty", "name", "comment"]: return True - + if column_name == "amount" and not self.get(index, "formula"): return True @@ -458,7 +375,7 @@ def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: dict: The parameters in scope. """ from activity_browser.bwutils.commontasks import parameters_in_scope - + row = self.row(index) if row is None: return {} @@ -469,167 +386,3 @@ def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: return parameters_in_scope(parameter=parameter) - -class ParameterizedExchangesView(widgets.ABTreeView): - """ - A view that displays parameterized exchanges in a tree structure. - - Attributes: - defaultColumnDelegates (dict): The default column delegates for the view. - """ - defaultColumnDelegates = { - "amount": delegates.FloatDelegate, - "unit": delegates.StringDelegate, - "product": delegates.StringDelegate, - "producer": delegates.StringDelegate, - "location": delegates.StringDelegate, - "database": delegates.StringDelegate, - "formula": delegates.NewFormulaDelegate, - "comment": delegates.StringDelegate, - "uncertainty": delegates.UncertaintyDelegate, - } - - class ContextMenu(widgets.ABMenu): - """ - A context menu for the ParameterizedExchangesView. - """ - def __init__(self, pos, view: "ParameterizedExchangesView"): - """ - Initializes the ContextMenu. - - Args: - pos: The position of the context menu. - view (ParameterizedExchangesView): The view displaying the exchanges. - """ - super().__init__(view) - - index = view.indexAt(pos) - if index.isValid() and not view.model().isBranchNode(index): - row = view.model().row(index) - if row is not None: - output_key = row.get("_output_key") - if output_key: - # Open activity action - open_action = app.actions.ActivityOpen.get_QAction([output_key]) - open_action.setText("Open activity") - self.addAction(open_action) - - -class ParameterizedExchangesModel(core.ABTreeModel): - """ - A model representing the data for parameterized exchanges. - """ - - def __init__(self, parent=None): - """ - Initializes the ParameterizedExchangesModel. - - Args: - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(df=pd.DataFrame(), parent=parent) - - def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: - """ - Sets the data for the given index. - - Args: - index (QtCore.QModelIndex): The index to set data for. - value: The value to set. - role (int): The role for which to set the data. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if role != Qt.ItemDataRole.EditRole: - return False - - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return False - - exchange = row.get("_exchange") - if exchange is None: - return False - - if column_name in ["amount", "formula", "comment"]: - if column_name == "formula" and not str(value).strip(): - # Remove formula if empty - app.actions.ExchangeFormulaRemove.run([exchange]) - return True - - app.actions.ExchangeModify.run(exchange, {column_name.lower(): value}) - return True - - return False - - def decorationData(self, index: QtCore.QModelIndex) -> any: - """ - Provides decoration data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide decoration data. - - Returns: - The decoration data for the index. - """ - column_name = self.column_name(index) - - if column_name == "amount": - formula = self.get(index, "formula") - if pd.isna(formula) or formula is None or formula == "": - return icons.qicons.edit - return icons.qicons.parameterized - - return None - - def indexEditable(self, index: QtCore.QModelIndex) -> bool: - """ - Returns whether the index is editable. - - Args: - index (QtCore.QModelIndex): The index to check. - - Returns: - bool: True if the index is editable, False otherwise. - """ - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return False - - # Check if database is locked - exchange = row.get("_exchange") - if exchange and database_is_locked(exchange.output["database"]): - return False - - # Allow editing for specific columns - if column_name in ["amount", "formula", "comment"]: - return True - - return False - - def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: - """ - Returns the parameters in scope of the exchange at the given index. - - Args: - index (QtCore.QModelIndex): The index to get scoped parameters for. - - Returns: - dict: The parameters in scope. - """ - from activity_browser.bwutils.commontasks import parameters_in_scope - - row = self.row(index) - if row is None: - return {} - - exchange = row.get("_exchange") - if exchange is None: - return {} - - return parameters_in_scope(node=exchange.output) From ed48dfc9babcae908a344eaedc205ecb17f61b65 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 18 Nov 2025 16:55:51 +0100 Subject: [PATCH 128/267] Initial reimplementation of Marc's search --- .../app/panes/database_products.py | 47 +- activity_browser/bwutils/metadata/__init__.py | 4 +- .../bwutils/metadata/_sub_loader.py | 27 -- activity_browser/bwutils/metadata/loader.py | 5 + activity_browser/bwutils/metadata/metadata.py | 22 +- activity_browser/bwutils/metadata/searcher.py | 456 ++++++++++++++++++ activity_browser/bwutils/searchengine/base.py | 44 +- activity_browser/ui/core/tree_model.py | 5 +- activity_browser/ui/widgets/__init__.py | 1 + activity_browser/ui/widgets/text_edit.py | 14 +- 10 files changed, 550 insertions(+), 75 deletions(-) delete mode 100644 activity_browser/bwutils/metadata/_sub_loader.py create mode 100644 activity_browser/bwutils/metadata/searcher.py diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 41fba283a..52e683a15 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -44,15 +44,18 @@ def __init__(self, parent, db_name: str): super().__init__(parent) self.database = bd.Database(db_name) self.title = db_name + + # initialize the model self.model = ProductModel(parent=self, chunk_size=100) # Create the QTableView and set the model self.table_view = ProductView(self, db_name=db_name) self.table_view.setModel(self.model) - self.search = widgets.ABLineEdit(self) - self.search.setMaximumHeight(30) - self.search.setPlaceholderText("Quick Search") + self.search_bar = widgets.MetaDataAutoCompleteTextEdit(self) + self.search_bar.database_name = db_name + self.search_bar.setMaximumHeight(30) + self.search_bar.setPlaceholderText("Quick Search") # Create loading indicator with spinner self.loading_spinner = QtWidgets.QProgressBar() @@ -95,7 +98,7 @@ def build_layout(self): self.stacked_layout.addWidget(table_widget) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(self.search) + layout.addWidget(self.search_bar) layout.addLayout(self.stacked_layout) # Set the table view as the central widget of the window @@ -106,7 +109,7 @@ def connect_signals(self): app.signals.database.deleted.connect(self.on_database_deleted) self.table_view.filtered.connect(self.search_error) - self.search.textChangedDebounce.connect(self.table_view.setAllFilter) + self.search_bar.textChangedDebounce.connect(self.search) def on_metadata_changed(self, added, updated, deleted): # Check if primary data has finished loading @@ -135,7 +138,11 @@ def sync(self): df = self.build_df() df.reset_index(drop=True, inplace=True) self.model.set_dataframe(df) - for col in df.columns: + self.model.filter("db_p_pane", "`_rank` >= 0") # show all rows by default + + for col in self.model.columns(): + if col == "index": + continue index = self.model.columns().index(col) if df[col].isna().all(): self.table_view.hideColumn(index) @@ -158,6 +165,9 @@ def build_df(self) -> pd.DataFrame: processors = set(df["processor"].dropna().unique()) df = df.drop(processors, errors="ignore") + df["_rank"] = 0 + df.rename(columns={"id": "_id"}, inplace=True) + if not df.properties.isna().all(): props_df = df[df.properties.notna()] props_df = pd.DataFrame(list(props_df.get("properties")), index=props_df.key) @@ -170,7 +180,7 @@ def build_df(self) -> pd.DataFrame: how="left", ) - cols = ["name", "product", "categories", "unit", "location", "key", "processor", "type",] + cols = ["name", "product", "categories", "unit", "location", "key", "processor", "type", "_id", "_rank"] cols += [col for col in df.columns if col.startswith("property")] logger.debug(f"Built DatabaseProductsPane dataframe in {time() - t:.2f} seconds") @@ -195,13 +205,30 @@ def search_error(self, reset=False): reset (bool, optional): Whether to reset the search bar color. Defaults to False. """ if reset: - self.search.setPalette(app.application.palette()) + self.search_bar.setPalette(app.application.palette()) return - palette = self.search.palette() + palette = self.search_bar.palette() palette.setColor(QtGui.QPalette.ColorRole.Base, QtGui.QColor(255, 128, 128)) - self.search.setPalette(palette) + self.search_bar.setPalette(palette) + + def search(self, query: str): + """ + Applies the search query to the table view. + + Args: + query (str): The search query. + """ + if query.startswith("=") or query == "": + self.table_view.setAllFilter(query) + return + + results = app.metadata.search_database(query, database=self.database.name, logging=True) + results.reverse() + rank_map = {res: i for i, res in enumerate(results)} + self.model.df["_rank"] = self.model.df["_id"].map(rank_map).fillna(-1).astype(int) + self.model.sort("_rank", Qt.SortOrder.DescendingOrder) class ProductView(ui.widgets.ABTreeView): """ diff --git a/activity_browser/bwutils/metadata/__init__.py b/activity_browser/bwutils/metadata/__init__.py index 6138067fb..a49f85c5e 100644 --- a/activity_browser/bwutils/metadata/__init__.py +++ b/activity_browser/bwutils/metadata/__init__.py @@ -1 +1,3 @@ -from .metadata import MetaDataStore \ No newline at end of file +from .metadata import MetaDataStore + +from . import fields diff --git a/activity_browser/bwutils/metadata/_sub_loader.py b/activity_browser/bwutils/metadata/_sub_loader.py deleted file mode 100644 index c3c71876a..000000000 --- a/activity_browser/bwutils/metadata/_sub_loader.py +++ /dev/null @@ -1,27 +0,0 @@ -import sqlite3 -import pandas as pd -import pickle -import sys - -def load(fp: str, database_name: str, fields: list[str]): - con = sqlite3.connect(fp) - sql = f"SELECT data FROM activitydataset WHERE database = '{database_name}'" - raw_df = pd.read_sql(sql, con) - con.close() - - df = pd.DataFrame([pickle.loads(x) for x in raw_df["data"]]) - if df.empty: - return df - - df["key"] = list(zip(df["database"], df["code"])) - df.index = pd.MultiIndex.from_tuples(df["key"], names=["database", "code"]) - df = df.reindex(columns=fields)[fields] - return df - -if __name__ == '__main__': - filepath = sys.argv[1] - database_name = sys.argv[2] - columns = sys.argv[3:] - df = load(filepath, database_name, columns) - - sys.stdout.buffer.write(pickle.dumps(df)) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index cf1ae0490..e5b40219c 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -79,6 +79,7 @@ def secondary_load_project(self, secondary_df: pd.DataFrame, sqlite_db: str): self.mds.register_mutation(idx, "update") self.secondary_status = "done" + self._init_searcher() def load_database(self, database_name: str): from bw2data.backends import sqlite3_lci_db @@ -142,6 +143,10 @@ def _fix_categories(self, df: pd.DataFrame): # add new category to column self.mds.dataframe[col] = self.mds.dataframe[col].cat.add_categories(categories) + def _init_searcher(self): + from .searcher import MDSSearcher + self.mds.searcher = MDSSearcher(self.mds) + class SecondaryLoadThread(threading.Thread): """Thread for loading secondary metadata using multiprocessing Pool.""" diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index f3ecf0f75..d68b75ae6 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -1,6 +1,4 @@ -from time import time -from loguru import logger -from typing import Literal +from typing import Literal, Optional import pandas as pd @@ -19,9 +17,11 @@ def __new__(cls): def __init__(self): from .loader import MDSLoader from .updater import MDSUpdater + from .searcher import MDSSearcher if self._initialized: return + self._initialized = True self._dataframe = pd.DataFrame() @@ -31,8 +31,7 @@ def __init__(self): self.loader = MDSLoader(self) self.updater = MDSUpdater(self) - - self._initialized = True + self.searcher: MDSSearcher | None = None # initialized by the loader @property def dataframe(self) -> pd.DataFrame: @@ -96,3 +95,16 @@ def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFr if db_name not in self.databases: return pd.DataFrame(columns=all) return self.dataframe.loc[[db_name], columns or all] + + def search(self, query: str) -> list[int]: + return self.searcher.search(query) + + def search_database(self, query: str, database: Optional[str] = None, return_counter: bool = False, logging: bool = True): + # we do fuzzy search as we re-index results (combining products and activities) for database_products table + # anyway, so including literal results quite literally is a waste of time at this point + return self.searcher.fuzzy_search(query, database=database, return_counter=return_counter, logging=logging) + + def auto_complete(self, word: str, context: Optional[set] = None, database: Optional[str] = None): + word = self.searcher.clean_text(word) + completions = self.searcher.auto_complete(word, context=context, database=database) + return completions diff --git a/activity_browser/bwutils/metadata/searcher.py b/activity_browser/bwutils/metadata/searcher.py new file mode 100644 index 000000000..68fca1bfb --- /dev/null +++ b/activity_browser/bwutils/metadata/searcher.py @@ -0,0 +1,456 @@ +from itertools import permutations +from collections import Counter, OrderedDict +from logging import getLogger +from time import time +from typing import Optional + +import pandas as pd + +from activity_browser.bwutils.searchengine import SearchEngine + +from .metadata import MetaDataStore +from .fields import all + +log = getLogger(__name__) + + +class MDSSearcher(SearchEngine): + + def __init__(self, mds: MetaDataStore): + self.mds = mds + super().__init__(self.mds.dataframe, "id", all) + + # caching for faster operation + def database_id_manager(self, database): + if not hasattr(self, "all_database_ids"): + self.all_database_ids = {} + + if database_ids := self.all_database_ids.get(database): + self.database_ids = database_ids + self.current_database = database + elif database is not None: + self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) + self.all_database_ids[database] = self.database_ids + self.current_database = database + else: + self.database_ids = None + self.current_database = "_@@NO_DB_" + return self.database_ids + + def reset_database_id_manager(self): + if hasattr(self, "all_database_ids"): + del self.all_database_ids + if hasattr(self, "database_ids"): + del self.database_ids + + def database_word_manager(self, database): + if not hasattr(self, "all_database_words"): + self.all_database_words = {} + + if database_words := self.all_database_words.get(database): + self.database_words = database_words + elif database is not None: + ids = self.database_id_manager(database) + self.database_words = self.reverse_dict_many_to_one({_id: self.identifier_to_word[_id] for _id in ids}) + self.all_database_words[database] = self.database_words + else: + self.database_words = None + return self.database_words + + def reset_database_word_manager(self, database): + if hasattr(self, "all_database_words") and self.all_database_words.get(database): + del self.all_database_words[database] + if hasattr(self, "database_words"): + del self.database_words + + def database_search_cache(self, database, query, result=None): + if not hasattr(self, "search_cache"): + self.search_cache = {} + + if result: + if self.search_cache.get(database): + self.search_cache[database][query] = result + else: + self.search_cache[database] = {query: result} + return + if db_cache := self.search_cache.get(database): + if cached_result := db_cache.get(query): + return cached_result + return + + def reset_search_cache(self, database): + if hasattr(self, "search_cache") and self.search_cache.get(database): + del self.search_cache[database] + + def reset_all_caches(self, databases): + self.reset_database_id_manager() + for database in databases: + self.reset_database_word_manager(database) + self.reset_search_cache(database) + + def add_identifier(self, data: pd.DataFrame) -> None: + super().add_identifier(data) + self.reset_all_caches(data["database"].unique()) + + def remove_identifiers(self, identifiers, logging=True) -> None: + t = time() + + identifiers = set(identifiers) + current_identifiers = set(self.df.index.to_list()) + identifiers = identifiers | current_identifiers # only remove identifiers currently in the data + databases = self.df.loc[identifiers, ["databases"]].unique() # extract databases for cache cleaning + if len(identifiers) == 0: + return + + for identifier in identifiers: + super().remove_identifier(identifier, logging=False) + + if logging: + log.debug(f"Search index updated in {time() - t:.2f} seconds " + f"for {len(identifiers)} removed items ({len(self.df)} items ({self.size_of_index()}) currently).") + self.reset_all_caches(databases) + + def change_identifier(self, identifier, data: pd.DataFrame) -> None: + super().change_identifier(identifier, data) + self.reset_all_caches(data["database"].unique()) + + def auto_complete(self, word: str, context: Optional[set] = set(), database: Optional[str] = None) -> list: + """Based on spellchecker, make more useful for autocompletions + """ + + def word_to_identifier_to_word(check_word): + if len(context) == 0: + return 1 + multiplier = 1 + for identifier in self.word_to_identifier[check_word]: + for context_word in context: + for spell_checked_context_word in spell_checked_context[context_word]: + if spell_checked_context_word in self.identifier_to_word[identifier]: + multiplier += 1 + if context_word not in self.word_to_identifier.keys(): + continue + if context_word in self.identifier_to_word[identifier]: + multiplier += 4 + return multiplier + + # count occurrences of a word, count double so word_to_identifier_to_word will never multiply by 1 + count_occurrence = lambda x: sum(self.word_to_identifier[x].values()) * 2 + + if len(word) <= 1: + return [] + + self.database_id_manager(database) + + if len(context) > 0: + spell_checked_context = {} + for context_word in context: + spell_checked_context[context_word] = self.spell_check(context_word).get(context_word, [])[:5] + + matches_min = 2 # ideally we have at least this many alternatives + matches_max = 4 # ideally don't much more than this many matches + never_accept_this = 4 # values this edit distance or over always rejected + # or max 2/3 of len(word) if less than never_accept_this + never_accept_this = int(round(max(1, min((len(word) * 0.66), never_accept_this)), 0)) + + # first, find possible matches quickly + q_grams = self.text_to_positional_q_gram(word) + possible_matches = self.find_q_gram_matches(set(q_grams), return_all=True) + + first_matches = Counter() + other_matches = {} + probably_keys = Counter() # if we suspect it's a key hash, dump it at the end of the list + + # now, refine with edit distance + for row in possible_matches.itertuples(): + if word == row[1]: + continue + # find edit distance of same size strings + edit_distance = self.osa_distance(word, row[1][:len(word)], cutoff=never_accept_this) + if len(row[1]) == 32 and edit_distance <= 1: + probably_keys[row[1]] = 100 - edit_distance # keys need to be sorted on edit distance, not on occurence + elif edit_distance == 0: + first_matches[row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) + elif edit_distance < never_accept_this and len(first_matches) < matches_min: + if not other_matches.get(edit_distance): + other_matches[edit_distance] = Counter() + other_matches[edit_distance][row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) + else: + continue + + # add matches in correct order: + matches = [match for match, _ in first_matches.most_common()] + # if we have fewer matches than goal, add more 'less good' matches + if len(matches) < matches_min: + for i in range(1, never_accept_this): + # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives + if new := other_matches.get(i): + prev_num = 10e100 + for match, num in new.most_common(): + if num == prev_num: + matches.append(match) + elif num != prev_num and len(matches) <= matches_max: + matches.append(match) + else: + break + prev_num = num + + matches = matches + [match for match, _ in probably_keys.most_common()] + return matches + + def find_q_gram_matches(self, q_grams: set, return_all: bool = False) -> pd.DataFrame: + """Overwritten for extra database specific reduction of results. + """ + n_q_grams = len(q_grams) + + matches = {} + + # find words that match our q-grams + for q_gram in q_grams: + if words := self.q_gram_to_word.get(q_gram, False): + # q_gram exists in our search index + for word in words: + if isinstance(self.database_ids, set): + # DATABASE SPECIFIC now filter on whether word is in the database + in_db = False + for _id in self.word_to_identifier[word]: + if _id in self.database_ids: + in_db = True + break + else: + in_db = True + if in_db: + matches[word] = matches.get(word, 0) + words[word] + + # if we find no results, return an empty dataframe + if len(matches) == 0: + return pd.DataFrame({"word": [], "matches": []}) + + # otherwise, create a dataframe and + # reduce search results to most relevant results + matches = {"word": matches.keys(), "matches": matches.values()} + matches = pd.DataFrame(matches) + max_q = max(matches["matches"]) # this has the most matching q-grams + + # determine how many results we want to keep based on how good our results are + if not return_all: + min_q = min(max(max_q * 0.32, # have at least a third of q-grams of best match or... + max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? + 1)), # okay just do 1 q-gram if there are no more in the word + max_q) # never have min_q be over max_q + else: + min_q = 0 + + matches = matches[matches["matches"] >= min_q] + matches = matches.sort_values(by="matches", ascending=False) + matches = matches.reset_index(drop=True) + + return matches.iloc[:min(len(matches), 2500), :] # return at most this many results + + def search_size_1(self, queries: list, original_words: set, orig_word_weight=5, exact_word_weight=1) -> dict: + """Return a dict of {query_word: Counter(identifier)}. + + queries: is a list of len 1 tuple/lists of words that are a searched word or a 'spell checked' similar word + original words: a list of words actually searched for (not including spellchecked) + + orig_word_weight: additional weight to add to original words + exact_word_weight: additional weight to add to exact word matches (as opposed to be 'in' str) + + First, we find all matching words, creating a dict of words in 'queries' as keys and words matching that query word as list of values + Next, we convert this to identifiers and add weights: + Weight will be increased if matching 'orig_word_weight' or 'exact_word_weight' + """ + matches = {} + t2 = time() + # add each word in search index if query_word in word + for word in self.database_words.keys(): + for query in queries: + # query is list/tuple of len 1 + query_word = query[0] # only use the word + if query_word in word: + words = matches.get(query_word, []) + words.extend([word]) + matches[query_word] = words + + # now convert matched words to matched identifiers + matched_identifiers = {} + for word, matching_words in matches.items(): + if result := self.database_search_cache(self.current_database, word): + matched_identifiers[word] = result + continue + id_counter = matched_identifiers.get(word, Counter()) + for matched_word in matching_words: + weight = self.base_weight + + # add the word n times, where n is the weight, original search word is weighted higher than alternatives + if matched_word in original_words: + weight += orig_word_weight # increase weight for original word + if matched_word == word: + weight += exact_word_weight # increase weight for exact matching word + + id_counter = self.weigh_identifiers(self.database_words[matched_word], weight, id_counter) + matched_identifiers[word] = id_counter + self.database_search_cache(self.current_database, word, matched_identifiers[word]) + + return matched_identifiers + + def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter: bool = False, + logging: bool = True) -> list: + """Overwritten for extra database specific reduction of results. + """ + t = time() + text = text.strip() + + if len(text) == 0: + log.debug(f"Empty search, returned all items") + return self.df.index.to_list() + + # DATABASE SPECIFIC get the set of ids that is in this database + self.database_id_manager(database) + self.database_word_manager(database) + + queries = self.build_queries(text) + + # make list of unique original words + orig_words = OrderedDict() + for word in text.split(" "): + orig_words[word] = False + orig_words = orig_words.keys() + orig_words = {self.clean_text(word) for word in orig_words} + + # order the queries by the amount of words they contain + # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space + queries_by_size = OrderedDict() + longest_query = max([len(q) for q in queries]) + for query_len in range(1, longest_query + 1): + queries_by_size[query_len] = [q for q in queries if len(q) == query_len] + + # first handle queries of length 1 + query_to_identifier = self.search_size_1(queries_by_size[1], orig_words) + + # DATABASE SPECIFIC ensure all identifiers are in the database + if isinstance(self.database_ids, set): + new_q2i = {} + for word, _ids in query_to_identifier.items(): + keep = set.intersection(set(_ids.keys()), self.database_ids) + new_id_counter = Counter() + for _id in keep: + new_id_counter[_id] = _ids[_id] + if len(new_id_counter) > 0: + new_q2i[word] = new_id_counter + query_to_identifier = new_q2i + + # get all results into a df, we rank further later + all_identifiers = set() + for id_list in [id_list for id_list in query_to_identifier.values()]: + all_identifiers.update(id_list) + search_df = self.df.loc[list(all_identifiers)] + + # now, we search for combinations of query words and get only those identifiers + # we then reduce de search_df further for only those matching identifiers + # we then search the permutations of that set of words + for q_len, query_set in queries_by_size.items(): + if q_len == 1: + # we already did these above + continue + for query in query_set: + # get the intersection of all identifiers + # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query + # this ensures we only ever search data where ALL items occur to substantially reduce search-space + # finally, make this a Counter (with each item=1) so we can properly weigh things later + query_id_sets = [set(query_to_identifier.get(q_word)) for q_word in query if + query_to_identifier.get(q_word, False)] + if len(query_id_sets) == 0: + continue + query_identifier_set = set.intersection(*query_id_sets) + if len(query_identifier_set) == 0: + # there is no match for this combination of query words, skip + break + + # now we convert the query identifiers to a Counter of 'occurrence', + # where we weigh queries with only original words higher + query_identifiers = Counter() + for identifier in query_identifier_set: + weight = 0 + for query_word in query: + # if the query_word and identifier combination exist get score, otherwise 0 + weight += query_to_identifier.get(query_word, {}).get(identifier, 0) + + query_identifiers[identifier] = weight + + # we now add these identifiers to a counter for this query name, + query_name = " ".join(query) + + weight = self.base_weight * q_len + query_to_identifier[query_name] = self.weigh_identifiers(query_identifiers, weight, Counter()) + + # now search for all permutations of this query combined with a space + query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] + for query_perm in permutations(query): + query_perm_str = " ".join(query_perm) + if result := self.database_search_cache(self.current_database, query_perm_str): + new_ids = result + else: + mask = self.filter_dataframe(query_df, query_perm_str, search_columns=["query_col"]) + new_df = query_df.loc[mask].reset_index(drop=True) + if len(new_df) == 0: + # there is no match for this permutation of words, skip + continue + new_id_list = new_df[self.identifier_name] + + new_ids = Counter() + for new_id in new_id_list: + new_ids[new_id] = query_identifiers[new_id] + self.database_search_cache(self.current_database, query_perm_str, new_ids) + # we weigh a combination of words that is next also to each other even higher than just the words separately + query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, + query_to_identifier[query_name]) + # now finally, move to one object sorted list by highest score + all_identifiers = Counter() + for identifiers in query_to_identifier.values(): + all_identifiers += identifiers + + if return_counter: + return_this = all_identifiers + else: + # now sort on highest weights and make list type + return_this = [identifier[0] for identifier in all_identifiers.most_common()] + if logging: + log.debug( + f"Found {len(all_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return return_this + + def search(self, text, database: Optional[str] = None) -> list: + """Search the dataframe on this text, return a sorted list of identifiers.""" + t = time() + text = text.strip() + + if len(text) == 0: + log.debug(f"Empty search, returned all items") + return self.df.index.to_list() + + # get the set of ids that is in this database + self.database_id_manager(database) + + fuzzy_identifiers = self.fuzzy_search(text, database=database, logging=False) + if len(fuzzy_identifiers) == 0: + log.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return [] + + # take the fuzzy search sub-set of data and search it literally + df = self.df.loc[fuzzy_identifiers].copy() + + literal_identifiers = self.literal_search(text, df) + if len(literal_identifiers) == 0: + log.debug( + f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return fuzzy_identifiers + + # append any fuzzy identifiers that were not found in the literal search + literal_id_set = set(literal_identifiers) + remaining_fuzzy_identifiers = [ + _id for _id in fuzzy_identifiers if _id not in literal_id_set] + identifiers = literal_identifiers + remaining_fuzzy_identifiers + + log.debug( + f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return identifiers diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index 5b9127985..52116ab96 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -1,19 +1,17 @@ -from itertools import permutations, chain import itertools import functools -from collections import Counter, OrderedDict, defaultdict -from logging import getLogger import math import multiprocessing as mp -from time import time -from typing import Iterable, Optional -import pandas as pd -import numpy as np import re import sys +from collections import Counter, OrderedDict, defaultdict +from typing import Iterable, Optional +from time import time +from loguru import logger -log = getLogger(__name__) +import pandas as pd +import numpy as np class SearchEngine: @@ -42,7 +40,7 @@ class SearchEngine: def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: list = []): t = time() - log.debug(f"SearchEngine initializing for {len(df)} items") + logger.debug(f"SearchEngine initializing for {len(df)} items") # compile regex patterns for cleaning self.SUB_END_PATTERN = re.compile(r"[,.\"'`)\[\]}\\/\-−_:;+…]+(?=\s|$)") # remove these from end of word @@ -92,7 +90,7 @@ def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: l self.update_index(df) - log.debug(f"SearchEngine Initialized in {time() - t:.2f} seconds") + logger.debug(f"SearchEngine Initialized in {time() - t:.2f} seconds") # +++ Utility functions @@ -137,7 +135,7 @@ def update_dict(update_me: dict, new: dict) -> dict: size_msg = (f"{size_dif} changed items at {int(round(size_dif/(time() - t), 0))} items/sec " f"({size_new} items ({self.size_of_index()}) currently)") if size_dif > 1 \ else f"1 changed item ({size_new} items ({self.size_of_index()}) currently)" - log.debug(f"Search index updated in {time() - t:.2f} seconds for {size_msg}.") + logger.debug(f"Search index updated in {time() - t:.2f} seconds for {size_msg}.") def clean_text(self, text: str): """Clean a string so it doesn't contain weird characters or multiple spaces etc.""" @@ -309,11 +307,10 @@ def add_identifier(self, data: pd.DataFrame) -> None: # update the search index data self.update_index(data) - def remove_identifier(self, identifier, logging=True) -> None: + def remove_identifier(self, identifier) -> None: """Remove this identifier from self.df and the search index. """ - if logging: - t = time() + t = time() # make sure the identifier exists if identifier not in self.df.index.to_list(): @@ -349,8 +346,7 @@ def remove_identifier(self, identifier, logging=True) -> None: # finally, remove the identifier del self.identifier_to_word[identifier] - if logging: - log.debug(f"Search index updated in {time() - t:.2f} seconds " + logger.debug(f"Search index updated in {time() - t:.2f} seconds " f"for 1 removed item ({len(self.df)} items ({self.size_of_index()}) currently).") def change_identifier(self, identifier, data: pd.DataFrame) -> None: @@ -373,7 +369,7 @@ def change_identifier(self, identifier, data: pd.DataFrame) -> None: raise Exception( "Identifier field cannot be changed, first remove item and then add new identifier") if "query_col" in data.keys(): - log.debug( + logger.debug( f"Field 'query_col' is a protected field for search engine and will be ignored for changing {identifier}") @@ -385,7 +381,7 @@ def change_identifier(self, identifier, data: pd.DataFrame) -> None: update_data[col] = [value] # remove the entry - self.remove_identifier(identifier, logging=False) + self.remove_identifier(identifier, loggerging=False) # add entry with updated data self.add_identifier(update_data) @@ -608,7 +604,7 @@ def build_queries(self, query_text) -> list: # find all combinations of the query words as given queries = list(query_text.keys()) - subsets = list(chain.from_iterable( + subsets = list(itertools.chain.from_iterable( (itertools.combinations( queries, r) for r in range(1, len(queries) + 1)))) all_queries = [] @@ -757,7 +753,7 @@ def fuzzy_search(self, text: str, return_counter: bool = False) -> list: # now search for all permutations of this query combined with a space query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] - for query_perm in permutations(query): + for query_perm in itertools.permutations(query): mask = self.filter_dataframe(query_df, " ".join(query_perm), search_columns=["query_col"]) new_df = query_df.loc[mask].reset_index(drop=True) if len(new_df) == 0: @@ -789,12 +785,12 @@ def search(self, text) -> list: text = text.strip() if len(text) == 0: - log.debug(f"Empty search, returned all items") + logger.debug(f"Empty search, returned all items") return self.df.index.to_list() fuzzy_identifiers = self.fuzzy_search(text) if len(fuzzy_identifiers) == 0: - log.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + logger.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") return [] # take the fuzzy search sub-set of data and search it literally @@ -802,7 +798,7 @@ def search(self, text) -> list: literal_identifiers = self.literal_search(text, df) if len(literal_identifiers) == 0: - log.debug( + logger.debug( f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") return fuzzy_identifiers @@ -812,6 +808,6 @@ def search(self, text) -> list: _id for _id in fuzzy_identifiers if _id not in literal_id_set] identifiers = literal_identifiers + remaining_fuzzy_identifiers - log.debug( + logger.debug( f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") return identifiers diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index d7b7b8c3a..97d6b0bff 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -1,6 +1,7 @@ from typing import Optional import pandas as pd +import numpy as np from PySide6 import QtGui from PySide6.QtCore import QModelIndex, Qt, QAbstractItemModel @@ -425,11 +426,11 @@ def reset_hierarchy(self, df: pd.DataFrame = None) -> None: self.layoutChanged.emit() - def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: + def sort(self, column: int | str, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: if self.df.empty: return # Extract the unique order of higher levels - column_name = self.headerData(column) if column > 0 else None + column_name = self.headerData(column) if isinstance(column, int) else column higher_levels = self.df.index.droplevel(-1).unique() if self.df.index.nlevels > 1 else [None] # Build a new index by sorting only within each higher level diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index d4256af60..9f7856b05 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -4,6 +4,7 @@ from .cutoff_menu import CutoffMenu from .line_edit import (ABLineEdit, SignalledComboEdit, SignalledLineEdit, SignalledPlainTextEdit) +from .text_edit import (ABAutoCompleTextEdit, ABTextEdit, MetaDataAutoCompleteTextEdit) from .line import ABHLine, ABVLine from .formula_edit import ABFormulaEdit diff --git a/activity_browser/ui/widgets/text_edit.py b/activity_browser/ui/widgets/text_edit.py index 9daf4fabe..a665376a8 100644 --- a/activity_browser/ui/widgets/text_edit.py +++ b/activity_browser/ui/widgets/text_edit.py @@ -3,8 +3,6 @@ from qtpy.QtGui import QSyntaxHighlighter, QTextCharFormat, QTextDocument, QFont from qtpy.QtWidgets import QCompleter, QStyledItemDelegate, QStyle -from activity_browser.bwutils import AB_metadata - class UnknownWordHighlighter(QSyntaxHighlighter): def __init__(self, parent: QTextDocument, known_words: set): @@ -93,7 +91,11 @@ def setDebounce(self, ms: int): class ABAutoCompleTextEdit(ABTextEdit): def __init__(self, parent=None, highlight_unknown=False): + from activity_browser.bwutils.metadata import MetaDataStore # avoid circular import, should we refactor? + + self.mds = MetaDataStore() super().__init__(parent=parent) + self.auto_complete_word = "" # autocompleter settings @@ -174,7 +176,7 @@ def __init__(self, parent=None): def _sanitize_input(self): self._debounce_timer.stop() text = self.toPlainText() - clean_text = AB_metadata.search_engine.ONE_SPACE_PATTERN.sub(" ", text) + clean_text = self.mds.searcher.ONE_SPACE_PATTERN.sub(" ", text) if clean_text != text: cursor = self.textCursor() @@ -187,8 +189,8 @@ def _sanitize_input(self): self.setTextCursor(cursor) known_words = set() - for identifier in AB_metadata.search_engine.database_id_manager(self.database_name): - known_words.update(AB_metadata.search_engine.identifier_to_word[identifier].keys()) + for identifier in self.mds.searcher.database_id_manager(self.database_name): + known_words.update(self.mds.searcher.identifier_to_word[identifier].keys()) self.highlighter.known_words = known_words if len(text) == 0: @@ -226,7 +228,7 @@ def _set_autocomplete_items(self): context = set((text[:start] + text[end:]).split(" ")) self.delegate.current_word_index = len(text[:start].split(" ")) # current word index for bolding # get suggestions for the current word - suggestions = AB_metadata.auto_complete(current_word, context=context, database=self.database_name) + suggestions = self.mds.searcher.auto_complete(current_word, context=context, database=self.database_name) suggestions = suggestions[:6] # at most 6, though we should get ~3 usually # replace the current word with each alternative items = [] From 89d3e689227c2a0f362d9db6e959d7e873953811 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 21 Nov 2025 09:47:28 +0100 Subject: [PATCH 129/267] Skip linking page when nothing to link --- .../app/actions/database/database_importer_excel.py | 10 +++++++++- activity_browser/ui/widgets/wizard.py | 1 + activity_browser/ui/widgets/wizard_page.py | 3 +++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/activity_browser/app/actions/database/database_importer_excel.py b/activity_browser/app/actions/database/database_importer_excel.py index 66249b8ff..789137a90 100644 --- a/activity_browser/app/actions/database/database_importer_excel.py +++ b/activity_browser/app/actions/database/database_importer_excel.py @@ -83,6 +83,10 @@ def finalize(self, context: dict): context["database_name"] = self.db_name_edit.text() def nextPage(self): + importer = self.context()["importer"] + link_dbs = set([exc["database"] for exc in importer.unlinked if exc["database"] != importer.db_name]) + if not link_dbs: + return ImportSetup.InstallPage return ImportSetup.DatabaseLink class DatabaseLink(widgets.ABWizardPage): @@ -140,6 +144,10 @@ def run_safely(self, importer: ABExcelImporter, database_name: str, linking_dict def initializePage(self, context: dict): """Start the download thread""" - self.thread.start(context["importer"], context["database_name"], context["linking_dict"]) + importer = context["importer"] + database_name = context["database_name"] + linking_dict = context.get("linking_dict", {}) + + self.thread.start(importer, database_name, linking_dict) pages = [ExtractPage, DatabaseName, DatabaseLink, InstallPage] diff --git a/activity_browser/ui/widgets/wizard.py b/activity_browser/ui/widgets/wizard.py index aa2834c8f..c0cd611bb 100644 --- a/activity_browser/ui/widgets/wizard.py +++ b/activity_browser/ui/widgets/wizard.py @@ -7,6 +7,7 @@ class ABWizard(QtWidgets.QWizard): pages = [] + context = {} def __init__(self, *args, title: str = None, context: dict = None, **kwargs): super().__init__(*args, **kwargs) diff --git a/activity_browser/ui/widgets/wizard_page.py b/activity_browser/ui/widgets/wizard_page.py index 295d6288f..e38190c23 100644 --- a/activity_browser/ui/widgets/wizard_page.py +++ b/activity_browser/ui/widgets/wizard_page.py @@ -36,6 +36,9 @@ def initializePage(self, context: dict): def finalize(self, context: dict): pass + def context(self) -> dict: + return self.wizard().context + class ABThreadedWizardPage(ABWizardPage): Thread: type["ABThread"] From d467c0d70d18d95496d5104c523e6d17c9e2361b Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 21 Nov 2025 11:58:34 +0100 Subject: [PATCH 130/267] Updates to the ABWizard --- .../database/database_importer_excel.py | 4 + activity_browser/ui/widgets/wizard.py | 56 ++++++++++++- activity_browser/ui/widgets/wizard_page.py | 3 +- .../widgets/test_wizard_button_visibility.py | 81 +++++++++++++++++++ 4 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 tests/widgets/test_wizard_button_visibility.py diff --git a/activity_browser/app/actions/database/database_importer_excel.py b/activity_browser/app/actions/database/database_importer_excel.py index 789137a90..756960e31 100644 --- a/activity_browser/app/actions/database/database_importer_excel.py +++ b/activity_browser/app/actions/database/database_importer_excel.py @@ -41,6 +41,7 @@ class ImportSetup(widgets.ABWizard): class ExtractPage(widgets.ABThreadedWizardPage): title = "Extracting Database" subtitle = "Extracting database from excel file" + buttonLayout = ["Stretch", "CancelButton", "NextButton"] class Thread(threading.ABThread): loaded: SignalInstance = Signal(object) @@ -60,6 +61,7 @@ def nextPage(self) -> type[QtWidgets.QWizardPage] | None: class DatabaseName(widgets.ABWizardPage): title = "Database Name" subtitle = "Enter the name of the database you wish to create" + buttonLayout = ["Stretch", "CancelButton", "NextButton"] def __init__(self, parent=None): super().__init__(parent) @@ -78,6 +80,8 @@ def isComplete(self): def initializePage(self, context: dict): self.db_name_edit.setText(context["importer"].db_name) + if self.nextPage() == ImportSetup.InstallPage: + self.buttonLayout = ["Stretch", "CancelButton", "CommitButton"] def finalize(self, context: dict): context["database_name"] = self.db_name_edit.text() diff --git a/activity_browser/ui/widgets/wizard.py b/activity_browser/ui/widgets/wizard.py index c0cd611bb..286f0419a 100644 --- a/activity_browser/ui/widgets/wizard.py +++ b/activity_browser/ui/widgets/wizard.py @@ -1,18 +1,35 @@ -from typing import TYPE_CHECKING -from qtpy import QtWidgets +from typing import TYPE_CHECKING, Literal +from qtpy import QtWidgets, QtCore if TYPE_CHECKING: from activity_browser.ui.widgets import ABWizardPage +ABWizardButtonLayout = list[Literal[ + "Stretch", + "BackButton", + "NextButton", + "CancelButton", + "FinishButton", + "HelpButton", + "CommitButton", +]] + class ABWizard(QtWidgets.QWizard): pages = [] context = {} + defaultButtonLayout: ABWizardButtonLayout = ["Stretch", "BackButton", "NextButton", "CancelButton"] + finalButtonLayout: ABWizardButtonLayout = ["Stretch", "FinishButton"] def __init__(self, *args, title: str = None, context: dict = None, **kwargs): super().__init__(*args, **kwargs) self.setWizardStyle(QtWidgets.QWizard.WizardStyle.ModernStyle) + self.setWindowFlags( + QtCore.Qt.WindowType.Sheet | + QtCore.Qt.WindowType.CustomizeWindowHint | + QtCore.Qt.WindowType.WindowTitleHint + ) if title: self.setWindowTitle(title) @@ -36,3 +53,38 @@ def initializePage(self, page_id): # initialize the next page page = self.page(page_id) page.initializePage(self.context) + if page.buttonLayout: + if "CommitButton" in page.buttonLayout: + page.setCommitPage(True) + if "FinishButton" in page.buttonLayout: + page.setFinalPage(True) + self.setButtonLayout(page.buttonLayout) + elif self.currentId() == self.pageIds()[-1]: + self.setButtonLayout(self.finalButtonLayout) + else: + self.setButtonLayout(self.defaultButtonLayout) + + def setButtonLayout(self, layout: ABWizardButtonLayout): + button_map = { + "Stretch": QtWidgets.QWizard.WizardButton.Stretch, + "BackButton": QtWidgets.QWizard.WizardButton.BackButton, + "NextButton": QtWidgets.QWizard.WizardButton.NextButton, + "CancelButton": QtWidgets.QWizard.WizardButton.CancelButton, + "FinishButton": QtWidgets.QWizard.WizardButton.FinishButton, + "HelpButton": QtWidgets.QWizard.WizardButton.HelpButton, + "CommitButton": QtWidgets.QWizard.WizardButton.CommitButton, + } + qt_layout = [button_map[item] for item in layout] + super().setButtonLayout(qt_layout) + + default_button = "NextButton" + default_button = "FinishButton" if "FinishButton" in layout else default_button + default_button = "CommitButton" if "CommitButton" in layout else default_button + + # Set the default button after a short delay to ensure the UI is updated + def set_default(): + button = self.button(button_map[default_button]) + button.setFocus() + + QtCore.QTimer.singleShot(50, set_default) + diff --git a/activity_browser/ui/widgets/wizard_page.py b/activity_browser/ui/widgets/wizard_page.py index e38190c23..446617b9a 100644 --- a/activity_browser/ui/widgets/wizard_page.py +++ b/activity_browser/ui/widgets/wizard_page.py @@ -2,13 +2,14 @@ from qtpy import QtWidgets if TYPE_CHECKING: - from activity_browser.ui.widgets import ABWizard + from .wizard import ABWizard, ABWizardButtonLayout from activity_browser.ui.core.threading import ABThread class ABWizardPage(QtWidgets.QWizardPage): title: str = "" subtitle: str = "" + buttonLayout: "ABWizardButtonLayout" = [] def __init__(self, parent=None): super().__init__(parent) diff --git a/tests/widgets/test_wizard_button_visibility.py b/tests/widgets/test_wizard_button_visibility.py new file mode 100644 index 000000000..21bb2a710 --- /dev/null +++ b/tests/widgets/test_wizard_button_visibility.py @@ -0,0 +1,81 @@ +"""Test wizard button visibility based on page.buttons property""" +import pytest +from qtpy import QtWidgets +from activity_browser.ui.widgets import ABWizard, ABWizardPage + + +class TestPage1(ABWizardPage): + """Test page with custom button configuration""" + title = "Page 1" + buttons = [ + QtWidgets.QWizard.WizardButton.NextButton, + QtWidgets.QWizard.WizardButton.CancelButton, + ] + + +class TestPage2(ABWizardPage): + """Test page with different button configuration""" + title = "Page 2" + buttons = [ + QtWidgets.QWizard.WizardButton.BackButton, + QtWidgets.QWizard.WizardButton.FinishButton, + QtWidgets.QWizard.WizardButton.CancelButton, + ] + + +class TestPage3(ABWizardPage): + """Test page using default button configuration""" + title = "Page 3" + + +class TestWizard(ABWizard): + """Test wizard with multiple pages""" + pages = [TestPage1, TestPage2, TestPage3] + + +def test_wizard_button_visibility_first_page(qtbot): + """Test that buttons are visible/hidden correctly on the first page""" + wizard = TestWizard() + qtbot.addWidget(wizard) + + # Initialize first page + wizard.restart() + + # Check button visibility for first page (only Next and Cancel should be visible) + assert not wizard.button(QtWidgets.QWizard.WizardButton.BackButton).isVisible() + assert wizard.button(QtWidgets.QWizard.WizardButton.NextButton).isVisible() + assert wizard.button(QtWidgets.QWizard.WizardButton.CancelButton).isVisible() + assert not wizard.button(QtWidgets.QWizard.WizardButton.FinishButton).isVisible() + + +def test_wizard_button_visibility_second_page(qtbot): + """Test that buttons are visible/hidden correctly on the second page""" + wizard = TestWizard() + qtbot.addWidget(wizard) + + # Navigate to second page + wizard.restart() + wizard.next() + + # Check button visibility for second page (Back, Finish, Cancel should be visible) + assert wizard.button(QtWidgets.QWizard.WizardButton.BackButton).isVisible() + assert not wizard.button(QtWidgets.QWizard.WizardButton.NextButton).isVisible() + assert wizard.button(QtWidgets.QWizard.WizardButton.CancelButton).isVisible() + assert wizard.button(QtWidgets.QWizard.WizardButton.FinishButton).isVisible() + + +def test_wizard_button_visibility_third_page_default(qtbot): + """Test that default buttons are shown when page.buttons is not customized""" + wizard = TestWizard() + qtbot.addWidget(wizard) + + # Navigate to third page (uses default buttons) + wizard.restart() + wizard.next() + wizard.next() + + # Check that default buttons are visible (Back, Next, Cancel from ABWizardPage default) + assert wizard.button(QtWidgets.QWizard.WizardButton.BackButton).isVisible() + assert wizard.button(QtWidgets.QWizard.WizardButton.NextButton).isVisible() + assert wizard.button(QtWidgets.QWizard.WizardButton.CancelButton).isVisible() + From 8a7a0a717166a350fb4e0bbbb82253343a4174eb Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 21 Nov 2025 13:19:32 +0100 Subject: [PATCH 131/267] Updates to EI and excel importer for better flow --- .../database_import_from_ecoinvent.py | 11 +++++++++ .../database/database_importer_excel.py | 5 +++- activity_browser/ui/widgets/wizard.py | 23 +++++++++++++++++++ activity_browser/ui/widgets/wizard_page.py | 13 +++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) 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 45792b826..10e89eb5e 100644 --- a/activity_browser/app/actions/database/database_import_from_ecoinvent.py +++ b/activity_browser/app/actions/database/database_import_from_ecoinvent.py @@ -54,6 +54,7 @@ class RemoteOrLocalPage(widgets.ABWizardPage): """Wizard page to choose between remote or local ecoinvent release""" title = "Import from ecoinvent" subtitle = "Choose whether to import from a remote or local ecoinvent release." + buttonLayout = ["Stretch", "CancelButton", "NextButton"] def __init__(self, parent=None): super().__init__(parent) @@ -78,6 +79,7 @@ class LocalSelectPage(widgets.ABWizardPage): """Wizard page to select a local ecoinvent .7z file""" title = "Import from ecoinvent" subtitle = "Select local ecoinvent .7z." + buttonLayout = ["Stretch", "CancelButton", "BackButton", "NextButton"] def __init__(self, parent=None): super().__init__(parent) @@ -111,6 +113,7 @@ class LoginPage(widgets.ABWizardPage): """Wizard page to login with ecoinvent credentials""" title = "Login" subtitle = "Login with your ecoinvent credentials to authorize the download" + buttonLayout = ["Stretch", "CancelButton", "BackButton", "NextButton"] def __init__(self, parent=None): super().__init__(parent) @@ -171,6 +174,7 @@ class EcoinventVersionPage(widgets.ABWizardPage): """Wizard page to choose ecoinvent version and system model""" title = "Choose version" subtitle = "Choose ecoinvent version and system model" + buttonLayout = ["Stretch", "CancelButton", "BackButton", "NextButton"] def __init__(self, parent=None): super().__init__(parent) @@ -210,6 +214,7 @@ class EcoinventDownloadPage(widgets.ABThreadedWizardPage): """Wizard page to download the selected ecoinvent release""" title = "Download ecoinvent" subtitle = "Downloading the selected ecoinvent release" + buttonLayout = ["Stretch", "NextButton"] class Thread(threading.ABThread): """Thread to handle the download process""" @@ -254,6 +259,7 @@ class BiosphereSetupPage(widgets.ABWizardPage): """Wizard page to choose biosphere setup options""" title = "Biosphere setup" subtitle = "Choose whether to import the biosphere database or connect to an existing one" + buttonLayout = ["Stretch", "CancelButton", "CommitButton"] def __init__(self, parent=None): super().__init__(parent) @@ -304,6 +310,7 @@ class BiosphereInstallPage(widgets.ABThreadedWizardPage): """Wizard page to install the biosphere database""" title = "Installing biosphere database" subtitle = "Installing bundled biosphere database into the project" + buttonLayout = ["Stretch", "NextButton"] class Thread(threading.ABThread): """Thread to handle the biosphere installation process""" @@ -324,6 +331,7 @@ class MethodsSetupPage(widgets.ABWizardPage): """Wizard page to choose methods setup options""" title = "Methods setup" subtitle = "Choose whether to import methods from ecoinvent or from file" + buttonLayout = ["Stretch", "CommitButton"] def __init__(self, parent=None): super().__init__(parent) @@ -377,6 +385,7 @@ class MethodsInstallPage(widgets.ABThreadedWizardPage): """Wizard page to install the selected methods""" title = "Installing methods" subtitle = "Installing selected methods and linking to the biosphere" + buttonLayout = ["Stretch", "NextButton"] class Thread(threading.ABThread): """Thread to handle the methods installation process""" @@ -409,6 +418,7 @@ class EcoinventSetupPage(widgets.ABWizardPage): """Wizard page to set up the ecoinvent database""" title = "Ecoinvent setup" subtitle = "Choose name for ecoinvent database" + buttonLayout = ["Stretch", "CancelButton", "CommitButton"] def __init__(self, parent=None): super().__init__(parent) @@ -440,6 +450,7 @@ class EcoinventInstallPage(widgets.ABThreadedWizardPage): """Wizard page to install the ecoinvent database""" title = "Installing ecoinvent" subtitle = "Installing ecoinvent database into the project" + buttonLayout = ["Stretch", "FinishButton"] class Thread(threading.ABThread): """Thread to handle the ecoinvent installation process""" diff --git a/activity_browser/app/actions/database/database_importer_excel.py b/activity_browser/app/actions/database/database_importer_excel.py index 756960e31..9cc46db06 100644 --- a/activity_browser/app/actions/database/database_importer_excel.py +++ b/activity_browser/app/actions/database/database_importer_excel.py @@ -41,7 +41,8 @@ class ImportSetup(widgets.ABWizard): class ExtractPage(widgets.ABThreadedWizardPage): title = "Extracting Database" subtitle = "Extracting database from excel file" - buttonLayout = ["Stretch", "CancelButton", "NextButton"] + buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "NextButton"] + customButton1Text = "Show extracted data" class Thread(threading.ABThread): loaded: SignalInstance = Signal(object) @@ -155,3 +156,5 @@ def initializePage(self, context: dict): self.thread.start(importer, database_name, linking_dict) pages = [ExtractPage, DatabaseName, DatabaseLink, InstallPage] + + diff --git a/activity_browser/ui/widgets/wizard.py b/activity_browser/ui/widgets/wizard.py index 286f0419a..8e2ab7e80 100644 --- a/activity_browser/ui/widgets/wizard.py +++ b/activity_browser/ui/widgets/wizard.py @@ -13,6 +13,9 @@ "FinishButton", "HelpButton", "CommitButton", + "CustomButton1", + "CustomButton2", + "CustomButton3", ]] class ABWizard(QtWidgets.QWizard): @@ -53,14 +56,31 @@ def initializePage(self, page_id): # initialize the next page page = self.page(page_id) page.initializePage(self.context) + if page.buttonLayout: if "CommitButton" in page.buttonLayout: page.setCommitPage(True) if "FinishButton" in page.buttonLayout: page.setFinalPage(True) + self.setButtonLayout(page.buttonLayout) + + if "CustomButton1" in page.buttonLayout: + btn = self.button(QtWidgets.QWizard.WizardButton.CustomButton1) + btn.clicked.connect(page.onCustomButon1Clicked) + self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton1, page.customButton1Text or "Custom 1") + if "CustomButton2" in page.buttonLayout: + btn = self.button(QtWidgets.QWizard.WizardButton.CustomButton2) + btn.clicked.connect(page.onCustomButon2Clicked) + self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton2, page.customButton2Text or "Custom 2") + if "CustomButton3" in page.buttonLayout: + btn = self.button(QtWidgets.QWizard.WizardButton.CustomButton3) + btn.clicked.connect(page.onCustomButon3Clicked) + self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton3, page.customButton3Text or "Custom 3") + elif self.currentId() == self.pageIds()[-1]: self.setButtonLayout(self.finalButtonLayout) + else: self.setButtonLayout(self.defaultButtonLayout) @@ -73,6 +93,9 @@ def setButtonLayout(self, layout: ABWizardButtonLayout): "FinishButton": QtWidgets.QWizard.WizardButton.FinishButton, "HelpButton": QtWidgets.QWizard.WizardButton.HelpButton, "CommitButton": QtWidgets.QWizard.WizardButton.CommitButton, + "CustomButton1": QtWidgets.QWizard.WizardButton.CustomButton1, + "CustomButton2": QtWidgets.QWizard.WizardButton.CustomButton2, + "CustomButton3": QtWidgets.QWizard.WizardButton.CustomButton3, } qt_layout = [button_map[item] for item in layout] super().setButtonLayout(qt_layout) diff --git a/activity_browser/ui/widgets/wizard_page.py b/activity_browser/ui/widgets/wizard_page.py index 446617b9a..a070c2f7c 100644 --- a/activity_browser/ui/widgets/wizard_page.py +++ b/activity_browser/ui/widgets/wizard_page.py @@ -11,6 +11,10 @@ class ABWizardPage(QtWidgets.QWizardPage): subtitle: str = "" buttonLayout: "ABWizardButtonLayout" = [] + customButton1Text: str = "" + customButton2Text: str = "" + customButton3Text: str = "" + def __init__(self, parent=None): super().__init__(parent) self.setTitle(self.title) @@ -40,6 +44,15 @@ def finalize(self, context: dict): def context(self) -> dict: return self.wizard().context + def onCustomButon1Clicked(self): + pass + + def onCustomButon2Clicked(self): + pass + + def onCustomButon3Clicked(self): + pass + class ABThreadedWizardPage(ABWizardPage): Thread: type["ABThread"] From 5539943487710f3c2ef18b89cbe660a822d551c1 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 21 Nov 2025 14:54:20 +0100 Subject: [PATCH 132/267] First iteration on the import preview dialog --- activity_browser/app/__init__.py | 1 + .../database/database_importer_excel.py | 17 ++++- activity_browser/app/dialogs/__init__.py | 1 + .../app/dialogs/import_preview_dialog.py | 63 +++++++++++++++++++ activity_browser/ui/core/tree_model.py | 2 + activity_browser/ui/delegates/string.py | 1 + 6 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 activity_browser/app/dialogs/__init__.py create mode 100644 activity_browser/app/dialogs/import_preview_dialog.py diff --git a/activity_browser/app/__init__.py b/activity_browser/app/__init__.py index b4c28d862..95bf10576 100644 --- a/activity_browser/app/__init__.py +++ b/activity_browser/app/__init__.py @@ -19,6 +19,7 @@ from . import actions from . import panes from . import pages +from . import dialogs main_window = MainWindow() application.main_window = main_window diff --git a/activity_browser/app/actions/database/database_importer_excel.py b/activity_browser/app/actions/database/database_importer_excel.py index 9cc46db06..c5afe4f9d 100644 --- a/activity_browser/app/actions/database/database_importer_excel.py +++ b/activity_browser/app/actions/database/database_importer_excel.py @@ -54,7 +54,22 @@ def run_safely(self, path: str): def initializePage(self, context: dict): """Start the download thread""" self.thread.start(context["path"]) - self.thread.loaded.connect(lambda i: context.__setitem__("importer", i)) + self.thread.loaded.connect(self.thread_finished) + + button = self.wizard().button(QtWidgets.QWizard.CustomButton1) + button.setEnabled(False) + + def thread_finished(self, importer: ABExcelImporter): + logger.debug("Extraction thread finished") + self.context()["importer"] = importer + + button = self.wizard().button(QtWidgets.QWizard.CustomButton1) + button.setEnabled(True) + + def onCustomButon1Clicked(self): + importer: ABExcelImporter = self.context()["importer"] + dialog = app.dialogs.ImportPreviewDialog(importer, parent=app.main_window) + dialog.exec_() def nextPage(self) -> type[QtWidgets.QWizardPage] | None: return ImportSetup.DatabaseName diff --git a/activity_browser/app/dialogs/__init__.py b/activity_browser/app/dialogs/__init__.py new file mode 100644 index 000000000..885add2a0 --- /dev/null +++ b/activity_browser/app/dialogs/__init__.py @@ -0,0 +1 @@ +from .import_preview_dialog import ImportPreviewDialog \ No newline at end of file diff --git a/activity_browser/app/dialogs/import_preview_dialog.py b/activity_browser/app/dialogs/import_preview_dialog.py new file mode 100644 index 000000000..a8d160766 --- /dev/null +++ b/activity_browser/app/dialogs/import_preview_dialog.py @@ -0,0 +1,63 @@ +from qtpy import QtWidgets, QtCore, QtGui + +import pandas as pd + +from bw2io.importers.base_lci import LCIImporter + +from activity_browser.ui import widgets, core + + +class ImportPreviewDialog(QtWidgets.QDialog): + standardNodeColumns = ["type", "name", "location", "unit", "categories", "code", "database"] + standardEdgeColumns = ["type", "amount", "unit", "name", "location", "database", "formula"] + + def __init__(self, importer: LCIImporter, parent=None): + super().__init__(parent) + self.setWindowTitle("Import Preview") + self.resize(600, 400) + + self.importer = importer + + layout = QtWidgets.QVBoxLayout(self) + + node_df = pd.DataFrame(importer.data) + node_df.drop(columns=["exchanges"], inplace=True, errors='ignore') + node_df = node_df[ + [col for col in self.standardNodeColumns if col in node_df.columns] + + [col for col in node_df.columns if col not in self.standardNodeColumns] + ] + + self.node_model = core.ABTreeModel(node_df) + self.node_view = widgets.ABTreeView() + self.node_view.setModel(self.node_model) + + self.exchanges_model = core.ABTreeModel() + self.exchanges_view = widgets.ABTreeView() + self.exchanges_view.setModel(self.exchanges_model) + self.exchanges_view.setHidden(True) + + self.node_view.clicked.connect(self.on_node_selected) + + button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Ok) + button_box.accepted.connect(self.accept) + + layout.addWidget(self.node_view) + layout.addWidget(self.exchanges_view) + + layout.addWidget(button_box) + + def on_node_selected(self, index: QtCore.QModelIndex): + exchanges = self.importer.data[index.row()].get('exchanges', []) + + if not exchanges: + self.exchanges_view.setHidden(True) + return + self.exchanges_view.setHidden(False) + + df = pd.DataFrame(exchanges) + df = df[ + [col for col in self.standardEdgeColumns if col in df.columns] + + [col for col in df.columns if col not in self.standardEdgeColumns] + ] + + self.exchanges_model.set_dataframe(df) \ No newline at end of file diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 97d6b0bff..f3d3c8800 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -55,6 +55,8 @@ class ABTreeModel(QAbstractItemModel): def __init__(self, df: pd.DataFrame = None, parent: Optional[QWidget] = None, chunk_size: int = -1) -> None: super().__init__(parent) self.df = df if df is not None else pd.DataFrame() + self.df.index = pd.MultiIndex.from_arrays([range(len(self.df))], names=[f"index"]) + self.df_query: dict[str, str] = {"model": "index == index"} # dictionary where queries can be registered self.filtered_columns: set[int] = set() # set of column indices that have active filters, only used for the header icon diff --git a/activity_browser/ui/delegates/string.py b/activity_browser/ui/delegates/string.py index 4ded24422..2708f2336 100644 --- a/activity_browser/ui/delegates/string.py +++ b/activity_browser/ui/delegates/string.py @@ -7,6 +7,7 @@ class StringDelegate(QtWidgets.QStyledItemDelegate): def displayText(self, value, locale): if isinstance(value, (list, tuple)): + value = [str(v) for v in value] return ", ".join(value) return str(value) From 02ea3d9a2329d195bb0fd69dc0fd8d46d794faf1 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 24 Nov 2025 09:39:19 +0100 Subject: [PATCH 133/267] Changes to the wizard setup --- .../database/database_importer_excel.py | 66 ++++++++++++++----- .../app/dialogs/import_preview_dialog.py | 4 +- activity_browser/bwutils/importers.py | 49 ++++++++++++++ activity_browser/ui/widgets/wizard.py | 43 +++++++----- activity_browser/ui/widgets/wizard_page.py | 13 ---- 5 files changed, 127 insertions(+), 48 deletions(-) diff --git a/activity_browser/app/actions/database/database_importer_excel.py b/activity_browser/app/actions/database/database_importer_excel.py index c5afe4f9d..6604add2b 100644 --- a/activity_browser/app/actions/database/database_importer_excel.py +++ b/activity_browser/app/actions/database/database_importer_excel.py @@ -38,6 +38,15 @@ def run(cls): class ImportSetup(widgets.ABWizard): + def customButtonOne(self): + def callback(): + importer : ABExcelImporter = self.context.get("importer") + if not importer: + return + dialog = app.dialogs.ImportPreviewDialog(importer, parent=app.main_window) + dialog.exec_() + return "Data", callback + class ExtractPage(widgets.ABThreadedWizardPage): title = "Extracting Database" subtitle = "Extracting database from excel file" @@ -49,6 +58,7 @@ class Thread(threading.ABThread): def run_safely(self, path: str): importer = ABExcelImporter(path) + importer.apply_basic_strategies() self.loaded.emit(importer) def initializePage(self, context: dict): @@ -66,18 +76,13 @@ def thread_finished(self, importer: ABExcelImporter): button = self.wizard().button(QtWidgets.QWizard.CustomButton1) button.setEnabled(True) - def onCustomButon1Clicked(self): - importer: ABExcelImporter = self.context()["importer"] - dialog = app.dialogs.ImportPreviewDialog(importer, parent=app.main_window) - dialog.exec_() - def nextPage(self) -> type[QtWidgets.QWizardPage] | None: return ImportSetup.DatabaseName class DatabaseName(widgets.ABWizardPage): title = "Database Name" subtitle = "Enter the name of the database you wish to create" - buttonLayout = ["Stretch", "CancelButton", "NextButton"] + buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "NextButton"] def __init__(self, parent=None): super().__init__(parent) @@ -96,22 +101,21 @@ def isComplete(self): def initializePage(self, context: dict): self.db_name_edit.setText(context["importer"].db_name) - if self.nextPage() == ImportSetup.InstallPage: - self.buttonLayout = ["Stretch", "CancelButton", "CommitButton"] + self.wizard().setButtonText(QtWidgets.QWizard.WizardButton.NextButton, "Apply") def finalize(self, context: dict): + importer = context["importer"] + importer.apply_db_name(self.db_name_edit.text()) + context["database_name"] = self.db_name_edit.text() def nextPage(self): - importer = self.context()["importer"] - link_dbs = set([exc["database"] for exc in importer.unlinked if exc["database"] != importer.db_name]) - if not link_dbs: - return ImportSetup.InstallPage return ImportSetup.DatabaseLink class DatabaseLink(widgets.ABWizardPage): title = "Link Databases" subtitle = "Link the imported database to existing databases" + buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "NextButton"] def __init__(self, parent=None): super().__init__(parent) @@ -144,10 +148,42 @@ def initializePage(self, context: dict): self.link_dict_edit[db] = drop_down - def finalize(self, context: dict): + importer = context["importer"] + importer.apply_linking({k: v.currentText() for k, v in self.link_dict_edit.items()}) + context["linking_dict"] = {k: v.currentText() for k, v in self.link_dict_edit.items()} + def nextPage(self): + return ImportSetup.ConfirmPage + + class ConfirmPage(widgets.ABWizardPage): + title = "Database Overview" + subtitle = "Confirming and installing the database" + buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "CommitButton"] + + def __init__(self, parent=None): + super().__init__(parent) + layout = QtWidgets.QGridLayout(self) + self.setLayout(layout) + + def isComplete(self): + return True + + def initializePage(self, context: dict): + importer = context["importer"] + layout = self.layout() + row = 0 + for key, value in { + "Database Name": importer.db_name, + "Number of Activities": len(importer.data), + "Number of Exchanges": sum(len(act.get("exchanges", [])) for act in importer.data), + "Number of Unlinked Exchanges": len(list(importer.unlinked)), + }.items(): + layout.addWidget(QtWidgets.QLabel(f"{key}:"), row, 0) + layout.addWidget(QtWidgets.QLabel(str(value)), row, 1) + row += 1 + def nextPage(self): return ImportSetup.InstallPage @@ -160,7 +196,7 @@ class Thread(threading.ABThread): def run_safely(self, importer: ABExcelImporter, database_name: str, linking_dict: dict): """Download the ecoinvent release""" - importer.automated_import(database_name, linking_dict) + importer.write_database() def initializePage(self, context: dict): """Start the download thread""" @@ -170,6 +206,6 @@ def initializePage(self, context: dict): self.thread.start(importer, database_name, linking_dict) - pages = [ExtractPage, DatabaseName, DatabaseLink, InstallPage] + pages = [ExtractPage, DatabaseName, DatabaseLink, ConfirmPage, InstallPage] diff --git a/activity_browser/app/dialogs/import_preview_dialog.py b/activity_browser/app/dialogs/import_preview_dialog.py index a8d160766..487187948 100644 --- a/activity_browser/app/dialogs/import_preview_dialog.py +++ b/activity_browser/app/dialogs/import_preview_dialog.py @@ -8,7 +8,7 @@ class ImportPreviewDialog(QtWidgets.QDialog): - standardNodeColumns = ["type", "name", "location", "unit", "categories", "code", "database"] + standardNodeColumns = ["type", "name", "exchanges", "location", "unit", "categories", "code", "database"] standardEdgeColumns = ["type", "amount", "unit", "name", "location", "database", "formula"] def __init__(self, importer: LCIImporter, parent=None): @@ -21,7 +21,7 @@ def __init__(self, importer: LCIImporter, parent=None): layout = QtWidgets.QVBoxLayout(self) node_df = pd.DataFrame(importer.data) - node_df.drop(columns=["exchanges"], inplace=True, errors='ignore') + node_df["exchanges"] = node_df["exchanges"].apply(lambda x: len(x) if isinstance(x, list) else 0) node_df = node_df[ [col for col in self.standardNodeColumns if col in node_df.columns] + [col for col in node_df.columns if col not in self.standardNodeColumns] diff --git a/activity_browser/bwutils/importers.py b/activity_browser/bwutils/importers.py index 7eb428773..a5973726e 100644 --- a/activity_browser/bwutils/importers.py +++ b/activity_browser/bwutils/importers.py @@ -126,6 +126,7 @@ def automated_import(self, db_name: str, relink: dict = None) -> list: excs = [exc for exc in self.unlinked][:10] databases = {exc.get("database", "(name missing)") for exc in self.unlinked} raise StrategyError(excs, databases) + if self.project_parameters: self.write_project_parameters(delete_existing=False) db = self.write_database(delete_existing=True, activate_parameters=True) @@ -133,6 +134,54 @@ def automated_import(self, db_name: str, relink: dict = None) -> list: bd.parameters.recalculate() return [db] + def apply_basic_strategies(self): + self.apply_strategies([ + csv_restore_tuples, + csv_restore_booleans, + csv_numerize, + csv_drop_unknown, + csv_add_missing_exchanges_section, + csv_rewrite_product_key, + normalize_units, + normalize_biosphere_categories, + normalize_biosphere_names, + strip_biosphere_exc_locations, + set_code_by_activity_hash, + drop_falsey_uncertainty_fields_but_keep_zeros, + convert_uncertainty_types_to_integers, + hash_parameter_group, + convert_activity_parameters_to_list, + parse_JSON_fields, + ]) + + def apply_db_name(self, db_name: str): + """Apply a database name change strategy.""" + self.apply_strategy( + functools.partial(alter_database_name, old=self.db_name, new=db_name) + ) + self.db_name = db_name + + def apply_linking(self, relink: dict): + self.apply_strategies([ + functools.partial( + link_iterable_by_fields, + other=bd.Database(bd.config.biosphere), + kind="biosphere", + ), + link_technosphere_by_activity_hash, + ]) + + for db, new_db in relink.items(): + if db == "(name missing)": + self.apply_strategy( + functools.partial(link_exchanges_without_db, db=new_db) + ) + else: + self.apply_strategy( + functools.partial(relink_exchanges_with_db, old=db, new=new_db) + ) + + 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)): diff --git a/activity_browser/ui/widgets/wizard.py b/activity_browser/ui/widgets/wizard.py index 8e2ab7e80..a5d6572b1 100644 --- a/activity_browser/ui/widgets/wizard.py +++ b/activity_browser/ui/widgets/wizard.py @@ -5,7 +5,7 @@ from activity_browser.ui.widgets import ABWizardPage -ABWizardButtonLayout = list[Literal[ +ABWizardButtons = Literal[ "Stretch", "BackButton", "NextButton", @@ -13,10 +13,10 @@ "FinishButton", "HelpButton", "CommitButton", - "CustomButton1", - "CustomButton2", - "CustomButton3", -]] +] + +ABWizardButtonLayout = list[ABWizardButtons] + class ABWizard(QtWidgets.QWizard): pages = [] @@ -40,6 +40,18 @@ def __init__(self, *args, title: str = None, context: dict = None, **kwargs): for page in self.pages: self.addPage(page(self)) + text, callback = self.customButtonOne() + self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton1, text) + self.button(QtWidgets.QWizard.WizardButton.CustomButton1).clicked.connect(callback) + + text, callback = self.customButtonTwo() + self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton2, text) + self.button(QtWidgets.QWizard.WizardButton.CustomButton2).clicked.connect(callback) + + text, callback = self.customButtonThree() + self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton3, text) + self.button(QtWidgets.QWizard.WizardButton.CustomButton3).clicked.connect(callback) + self.context = context or {} def page(self, page_id: int) -> "ABWizardPage": @@ -65,19 +77,6 @@ def initializePage(self, page_id): self.setButtonLayout(page.buttonLayout) - if "CustomButton1" in page.buttonLayout: - btn = self.button(QtWidgets.QWizard.WizardButton.CustomButton1) - btn.clicked.connect(page.onCustomButon1Clicked) - self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton1, page.customButton1Text or "Custom 1") - if "CustomButton2" in page.buttonLayout: - btn = self.button(QtWidgets.QWizard.WizardButton.CustomButton2) - btn.clicked.connect(page.onCustomButon2Clicked) - self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton2, page.customButton2Text or "Custom 2") - if "CustomButton3" in page.buttonLayout: - btn = self.button(QtWidgets.QWizard.WizardButton.CustomButton3) - btn.clicked.connect(page.onCustomButon3Clicked) - self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton3, page.customButton3Text or "Custom 3") - elif self.currentId() == self.pageIds()[-1]: self.setButtonLayout(self.finalButtonLayout) @@ -111,3 +110,11 @@ def set_default(): QtCore.QTimer.singleShot(50, set_default) + def customButtonOne(self): + return "CustomButton1", lambda: None + + def customButtonTwo(self): + return "CustomButton2", lambda: None + + def customButtonThree(self): + return "CustomButton3", lambda: None diff --git a/activity_browser/ui/widgets/wizard_page.py b/activity_browser/ui/widgets/wizard_page.py index a070c2f7c..446617b9a 100644 --- a/activity_browser/ui/widgets/wizard_page.py +++ b/activity_browser/ui/widgets/wizard_page.py @@ -11,10 +11,6 @@ class ABWizardPage(QtWidgets.QWizardPage): subtitle: str = "" buttonLayout: "ABWizardButtonLayout" = [] - customButton1Text: str = "" - customButton2Text: str = "" - customButton3Text: str = "" - def __init__(self, parent=None): super().__init__(parent) self.setTitle(self.title) @@ -44,15 +40,6 @@ def finalize(self, context: dict): def context(self) -> dict: return self.wizard().context - def onCustomButon1Clicked(self): - pass - - def onCustomButon2Clicked(self): - pass - - def onCustomButon3Clicked(self): - pass - class ABThreadedWizardPage(ABWizardPage): Thread: type["ABThread"] From 9434e106c518ba8520a2b38912a91fdf14e70b67 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 24 Nov 2025 12:19:07 +0100 Subject: [PATCH 134/267] Update searcher to work with no database --- activity_browser/bwutils/metadata/searcher.py | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/activity_browser/bwutils/metadata/searcher.py b/activity_browser/bwutils/metadata/searcher.py index 68fca1bfb..54ef3fcf3 100644 --- a/activity_browser/bwutils/metadata/searcher.py +++ b/activity_browser/bwutils/metadata/searcher.py @@ -33,8 +33,13 @@ def database_id_manager(self, database): self.all_database_ids[database] = self.database_ids self.current_database = database else: - self.database_ids = None - self.current_database = "_@@NO_DB_" + # When database is None, search across all databases + if all_ids := self.all_database_ids.get(None): + self.database_ids = all_ids + else: + self.database_ids = set(self.df.index.to_list()) + self.all_database_ids[None] = self.database_ids + self.current_database = None return self.database_ids def reset_database_id_manager(self): @@ -54,7 +59,13 @@ def database_word_manager(self, database): self.database_words = self.reverse_dict_many_to_one({_id: self.identifier_to_word[_id] for _id in ids}) self.all_database_words[database] = self.database_words else: - self.database_words = None + # When database is None, search across all databases + if all_words := self.all_database_words.get(None): + self.database_words = all_words + else: + ids = self.database_id_manager(database) + self.database_words = self.reverse_dict_many_to_one({_id: self.identifier_to_word[_id] for _id in ids}) + self.all_database_words[None] = self.database_words return self.database_words def reset_database_word_manager(self, database): @@ -296,6 +307,15 @@ def search_size_1(self, queries: list, original_words: set, orig_word_weight=5, def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter: bool = False, logging: bool = True) -> list: """Overwritten for extra database specific reduction of results. + + Args: + text: Search query string + database: Database name to search within. If None, searches across all databases. + return_counter: If True, return a Counter instead of a list + logging: If True, log search timing information + + Returns: + List of identifiers (or Counter if return_counter=True) matching the search. """ t = time() text = text.strip() @@ -420,7 +440,15 @@ def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter return return_this def search(self, text, database: Optional[str] = None) -> list: - """Search the dataframe on this text, return a sorted list of identifiers.""" + """Search the dataframe on this text, return a sorted list of identifiers. + + Args: + text: Search query string + database: Database name to search within. If None, searches across all databases. + + Returns: + List of identifiers matching the search, sorted by relevance. + """ t = time() text = text.strip() From 9a42dba922bf5013e40c156564daeb1adeb8b91a Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 24 Nov 2025 17:23:32 +0100 Subject: [PATCH 135/267] Full project search dialog --- activity_browser/app/actions/__init__.py | 1 + .../app/actions/node_select_open.py | 22 +++ activity_browser/app/dialogs/__init__.py | 3 +- .../app/dialogs/node_select_dialog.py | 158 ++++++++++++++++++ 4 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 activity_browser/app/actions/node_select_open.py create mode 100644 activity_browser/app/dialogs/node_select_dialog.py diff --git a/activity_browser/app/actions/__init__.py b/activity_browser/app/actions/__init__.py index f3d12acb7..e2a484251 100644 --- a/activity_browser/app/actions/__init__.py +++ b/activity_browser/app/actions/__init__.py @@ -95,4 +95,5 @@ from .migrations_install import MigrationsInstall from .pyside_upgrade import PysideUpgrade from .metadatastore_open import MetaDataStoreOpen +from .node_select_open import NodeSelectOpen from .save_parameters_to_excel import SaveParametersToExcel diff --git a/activity_browser/app/actions/node_select_open.py b/activity_browser/app/actions/node_select_open.py new file mode 100644 index 000000000..070c848c8 --- /dev/null +++ b/activity_browser/app/actions/node_select_open.py @@ -0,0 +1,22 @@ +from loguru import logger + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons +from activity_browser.ui.core.application import global_shortcut + + + + +class NodeSelectOpen(ABAction): + + icon = qicons.right + text = "Open activity / activities" + + @staticmethod + @global_shortcut("Ctrl+Shift+N") + @exception_dialogs + def run(): + from activity_browser.app import dialogs + dialog = dialogs.NodeSelectDialog(parent=app.main_window) + dialog.exec_() \ No newline at end of file diff --git a/activity_browser/app/dialogs/__init__.py b/activity_browser/app/dialogs/__init__.py index 885add2a0..65e57911d 100644 --- a/activity_browser/app/dialogs/__init__.py +++ b/activity_browser/app/dialogs/__init__.py @@ -1 +1,2 @@ -from .import_preview_dialog import ImportPreviewDialog \ No newline at end of file +from .import_preview_dialog import ImportPreviewDialog +from .node_select_dialog import NodeSelectDialog diff --git a/activity_browser/app/dialogs/node_select_dialog.py b/activity_browser/app/dialogs/node_select_dialog.py new file mode 100644 index 000000000..cc31d6ead --- /dev/null +++ b/activity_browser/app/dialogs/node_select_dialog.py @@ -0,0 +1,158 @@ +from qtpy import QtWidgets, QtCore + +from activity_browser.ui import widgets +from activity_browser.app import metadata, actions + + +class NodeSelectDialog(QtWidgets.QDialog): + node_selected = QtCore.Signal(dict) + + def __init__(self, parent=None): + super().__init__(parent) + + self.setWindowFlags( + QtCore.Qt.WindowType.Sheet | + QtCore.Qt.WindowType.CustomizeWindowHint + ) + self.setModal(True) + self.setFixedWidth(400) + self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.Maximum) + + print(self.sizeHint()) + + self.edit = widgets.ABLineEdit(self) + self.edit.setPlaceholderText("Enter text to search for a node") + self.edit.textChangedDebounce.connect(self.on_search) + + # Create scroll area for results + self.scroll_area = QtWidgets.QScrollArea(self) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll_area.setFixedHeight(0) # Start with height 0 + + # Container widget for results + self.results_container = QtWidgets.QWidget() + self.results_layout = QtWidgets.QVBoxLayout(self.results_container) + self.results_layout.setContentsMargins(0, 0, 0, 0) + self.results_layout.setSpacing(2) + self.results_layout.addStretch() + + self.scroll_area.setWidget(self.results_container) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(5, 0, 5, 0) + layout.addWidget(self.edit) + layout.addWidget(self.scroll_area) + self.setLayout(layout) + + self.min_height = self.sizeHint().height() + + self.setFixedHeight(self.min_height) + + def showEvent(self, event): + """Position the dialog 200px higher than default centered position""" + super().showEvent(event) + if self.parent(): + parent_rect = self.parent().geometry() + dialog_rect = self.geometry() + + # Center horizontally, but move up 200px from center vertically + x = parent_rect.x() + (parent_rect.width() - dialog_rect.width()) // 2 + y = parent_rect.y() + (parent_rect.height() - dialog_rect.height()) // 2 - 200 + + self.move(x, y) + + def on_search(self, text: str): + # Clear existing results + while self.results_layout.count() > 1: # Keep the stretch item + item = self.results_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + if not text.strip(): + self.scroll_area.setFixedHeight(0) + self.adjustSize() + self.setFixedHeight(self.sizeHint().height()) + return + + result = metadata.search(text) + result = result[0:10] if len(result) > 10 else result + result.reverse() + result = metadata.dataframe.loc[metadata.dataframe["id"].isin(result)].copy() + result["rank_map"] = result["id"].apply(lambda x: result["id"].tolist().index(x)) + result = result.sort_values(by=["rank_map"]).drop(columns=["rank_map"]) + + # Create NodeResult widgets for each result + if len(result) > 0: + for idx, row in result.iterrows(): + node_data = row.to_dict() + node_widget = NodeResult(node_data, self) + node_widget.clicked.connect(self.on_node_selected) + self.results_layout.insertWidget(self.results_layout.count() - 1, node_widget) + # Set scroll area height to show results (max 300px) + self.scroll_area.setFixedHeight(400) + else: + self.scroll_area.setFixedHeight(0) + + # Adjust dialog to minimum size + self.adjustSize() + self.setFixedHeight(self.sizeHint().height()) + + def on_node_selected(self, node_data: dict): + """Handle when a node is clicked""" + self.node_selected.emit(node_data) + + actions.ActivityOpen.run([node_data.get("id")]) + + self.accept() # Close the dialog + +class NodeResult(QtWidgets.QFrame): + clicked = QtCore.Signal(dict) + + def __init__(self, node_data: dict, parent=None): + super().__init__(parent) + + self.node_data = node_data + + # Set frame shape for proper rendering + self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel) + + # Set cursor to pointer to indicate clickability + self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + + # Set minimum height + self.setMinimumHeight(40) + + # Apply stylesheet with actual color values + self.setStyleSheet(""" + NodeResult { + border: 1px solid palette(mid); + border-radius: 3px; + margin: 2px; + } + NodeResult:hover { + border: 1px solid palette(highlight); + } + """) + + layout = QtWidgets.QHBoxLayout(self) + + name_label = QtWidgets.QLabel( + f""" + {self.node_data.get('database', '')}
+ {self.node_data.get('name', '')}
+ {node_data.get('unit')} | {node_data.get('location')} | {node_data.get('type')} + """ + ) + name_label.setWordWrap(True) + name_label.setTextFormat(QtCore.Qt.TextFormat.RichText) + layout.addWidget(name_label) + + self.setLayout(layout) + + def mousePressEvent(self, event): + """Handle mouse click events""" + if event.button() == QtCore.Qt.MouseButton.LeftButton: + self.clicked.emit(self.node_data) + super().mousePressEvent(event) From 98034813d58d0908ea7277cc4db31a344b35ddfa Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 24 Nov 2025 17:24:30 +0100 Subject: [PATCH 136/267] Showing unlinked exchanges --- .../app/dialogs/import_preview_dialog.py | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/activity_browser/app/dialogs/import_preview_dialog.py b/activity_browser/app/dialogs/import_preview_dialog.py index 487187948..8ca72e1dc 100644 --- a/activity_browser/app/dialogs/import_preview_dialog.py +++ b/activity_browser/app/dialogs/import_preview_dialog.py @@ -8,8 +8,8 @@ class ImportPreviewDialog(QtWidgets.QDialog): - standardNodeColumns = ["type", "name", "exchanges", "location", "unit", "categories", "code", "database"] - standardEdgeColumns = ["type", "amount", "unit", "name", "location", "database", "formula"] + standardNodeColumns = ["type", "name", "exchanges", "unlinked_exchanges", "location", "unit", "categories", "code", "database"] + standardEdgeColumns = ["type", "amount", "unit", "input", "name", "location", "database", "formula"] def __init__(self, importer: LCIImporter, parent=None): super().__init__(parent) @@ -21,17 +21,22 @@ def __init__(self, importer: LCIImporter, parent=None): layout = QtWidgets.QVBoxLayout(self) node_df = pd.DataFrame(importer.data) + node_df["unlinked_exchanges"] = node_df["exchanges"].apply( + lambda x: sum(1 for ex in x if not ex.get("input")) if isinstance(x, list) else 0 + ) node_df["exchanges"] = node_df["exchanges"].apply(lambda x: len(x) if isinstance(x, list) else 0) + node_df = node_df[ [col for col in self.standardNodeColumns if col in node_df.columns] + [col for col in node_df.columns if col not in self.standardNodeColumns] ] + node_df["_importer_index"] = range(len(node_df)) self.node_model = core.ABTreeModel(node_df) self.node_view = widgets.ABTreeView() self.node_view.setModel(self.node_model) - self.exchanges_model = core.ABTreeModel() + self.exchanges_model = ImportPreviewExchangeModel(importer, self) self.exchanges_view = widgets.ABTreeView() self.exchanges_view.setModel(self.exchanges_model) self.exchanges_view.setHidden(True) @@ -47,7 +52,8 @@ def __init__(self, importer: LCIImporter, parent=None): layout.addWidget(button_box) def on_node_selected(self, index: QtCore.QModelIndex): - exchanges = self.importer.data[index.row()].get('exchanges', []) + importer_index = self.node_model.get(index, "_importer_index") + exchanges = self.importer.data[importer_index].get('exchanges', []) if not exchanges: self.exchanges_view.setHidden(True) @@ -55,9 +61,37 @@ def on_node_selected(self, index: QtCore.QModelIndex): self.exchanges_view.setHidden(False) df = pd.DataFrame(exchanges) + df["input"] = df.get("input", None) + df = df[ [col for col in self.standardEdgeColumns if col in df.columns] + [col for col in df.columns if col not in self.standardEdgeColumns] ] - self.exchanges_model.set_dataframe(df) \ No newline at end of file + self.exchanges_model.set_dataframe(df) + + +class ImportPreviewExchangeModel(core.ABTreeModel): + + def __init__(self, importer, parent=None): + super().__init__(parent=parent) + self.importer = importer + + def indexEditable(self, index: QtCore.QModelIndex) -> bool: + if self.column_name(index) == "input": + return True + return super().indexEditable(index) + + def displayData(self, index: QtCore.QModelIndex) -> any: + data = super().displayData(index) + + if self.column_name(index) == "input" and not data: + return "" + return data + + def fontData(self, index: QtCore.QModelIndex) -> any: + if self.get(index, "type") == "production": + font = QtGui.QFont() + font.setBold(True) + return font + return super().fontData(index) From c56911558100222f3537e4196583e671a62fdc91 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 25 Nov 2025 11:20:27 +0100 Subject: [PATCH 137/267] Simple view for the database_products table --- .../app/dialogs/node_select_dialog.py | 52 +------ .../app/panes/database_products.py | 60 +++++++- activity_browser/ui/delegates/__init__.py | 2 + activity_browser/ui/delegates/node.py | 56 +++++++ activity_browser/ui/widgets/__init__.py | 1 + activity_browser/ui/widgets/buttons.py | 4 +- activity_browser/ui/widgets/node_details.py | 138 ++++++++++++++++++ activity_browser/ui/widgets/tree_view.py | 4 +- 8 files changed, 258 insertions(+), 59 deletions(-) create mode 100644 activity_browser/ui/delegates/node.py create mode 100644 activity_browser/ui/widgets/node_details.py diff --git a/activity_browser/app/dialogs/node_select_dialog.py b/activity_browser/app/dialogs/node_select_dialog.py index cc31d6ead..b59a28c77 100644 --- a/activity_browser/app/dialogs/node_select_dialog.py +++ b/activity_browser/app/dialogs/node_select_dialog.py @@ -87,7 +87,7 @@ def on_search(self, text: str): if len(result) > 0: for idx, row in result.iterrows(): node_data = row.to_dict() - node_widget = NodeResult(node_data, self) + node_widget = widgets.NodeDetails(node_data, self) node_widget.clicked.connect(self.on_node_selected) self.results_layout.insertWidget(self.results_layout.count() - 1, node_widget) # Set scroll area height to show results (max 300px) @@ -106,53 +106,3 @@ def on_node_selected(self, node_data: dict): actions.ActivityOpen.run([node_data.get("id")]) self.accept() # Close the dialog - -class NodeResult(QtWidgets.QFrame): - clicked = QtCore.Signal(dict) - - def __init__(self, node_data: dict, parent=None): - super().__init__(parent) - - self.node_data = node_data - - # Set frame shape for proper rendering - self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel) - - # Set cursor to pointer to indicate clickability - self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) - - # Set minimum height - self.setMinimumHeight(40) - - # Apply stylesheet with actual color values - self.setStyleSheet(""" - NodeResult { - border: 1px solid palette(mid); - border-radius: 3px; - margin: 2px; - } - NodeResult:hover { - border: 1px solid palette(highlight); - } - """) - - layout = QtWidgets.QHBoxLayout(self) - - name_label = QtWidgets.QLabel( - f""" - {self.node_data.get('database', '')}
- {self.node_data.get('name', '')}
- {node_data.get('unit')} | {node_data.get('location')} | {node_data.get('type')} - """ - ) - name_label.setWordWrap(True) - name_label.setTextFormat(QtCore.Qt.TextFormat.RichText) - layout.addWidget(name_label) - - self.setLayout(layout) - - def mousePressEvent(self, event): - """Handle mouse click events""" - if event.button() == QtCore.Qt.MouseButton.LeftButton: - self.clicked.emit(self.node_data) - super().mousePressEvent(event) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 52e683a15..a257b94b2 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -1,3 +1,4 @@ +from PySide6.QtCore import QModelIndex from loguru import logger from time import time @@ -44,9 +45,10 @@ def __init__(self, parent, db_name: str): super().__init__(parent) self.database = bd.Database(db_name) self.title = db_name + self.simple = True # initialize the model - self.model = ProductModel(parent=self, chunk_size=100) + self.model = ProductModel(parent=self, chunk_size=50) # Create the QTableView and set the model self.table_view = ProductView(self, db_name=db_name) @@ -71,6 +73,11 @@ def __init__(self, parent, db_name: str): self.loading_label.setFont(font) self.loading_label.setStyleSheet("color: gray; padding: 10px;") + # Create simple/detailed view toggle + self.view_toggle = QtWidgets.QCheckBox("Detailed View") + self.view_toggle.setChecked(not self.simple) + self.view_toggle.setToolTip("Toggle between simple and detailed view") + self.build_layout() self.connect_signals() self.update_loading_state() @@ -97,8 +104,13 @@ def build_layout(self): table_layout.addWidget(self.table_view) self.stacked_layout.addWidget(table_widget) + # Create top bar with search and toggle + top_bar = QtWidgets.QHBoxLayout() + top_bar.addWidget(self.search_bar) + top_bar.addWidget(self.view_toggle) + layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(self.search_bar) + layout.addLayout(top_bar) layout.addLayout(self.stacked_layout) # Set the table view as the central widget of the window @@ -110,6 +122,7 @@ def connect_signals(self): self.table_view.filtered.connect(self.search_error) self.search_bar.textChangedDebounce.connect(self.search) + self.view_toggle.checkStateChanged.connect(self.on_mode_switch) def on_metadata_changed(self, added, updated, deleted): # Check if primary data has finished loading @@ -137,14 +150,18 @@ def sync(self): t = time() df = self.build_df() df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) self.model.filter("db_p_pane", "`_rank` >= 0") # show all rows by default + self.table_view.header().setHidden(self.simple) + for col in self.model.columns(): - if col == "index": + if col == "index" or col == "node": continue index = self.model.columns().index(col) - if df[col].isna().all(): + + if df[col].isna().all() or self.simple: self.table_view.hideColumn(index) else: self.table_view.showColumn(index) @@ -180,8 +197,13 @@ def build_df(self) -> pd.DataFrame: how="left", ) - cols = ["name", "product", "categories", "unit", "location", "key", "processor", "type", "_id", "_rank"] + df["node"] = None + + cols = ["name", "product", "categories", "unit", "location", "key", "processor", "type"] + if self.simple: + cols += ["node"] cols += [col for col in df.columns if col.startswith("property")] + cols += ["_id", "_rank"] logger.debug(f"Built DatabaseProductsPane dataframe in {time() - t:.2f} seconds") @@ -197,6 +219,16 @@ def on_database_deleted(self, db_name: str): if db_name == self.database.name: self.deleteLater() + def on_mode_switch(self, check: Qt.CheckState): + """ + Handles the mode switch between simple and detailed view. + + Args: + check (Qt.CheckState): The check state of the toggle. + """ + self.simple = check == Qt.CheckState.Unchecked + self.sync() + def search_error(self, reset=False): """ Handles the search error by changing the search bar color. @@ -241,6 +273,7 @@ class ProductView(ui.widgets.ABTreeView): "categories": delegates.ListDelegate, "key": delegates.StringDelegate, "processor": delegates.StringDelegate, + "node": delegates.NodeDelegate, } class ContextMenu(ui.widgets.ABMenu): @@ -363,6 +396,23 @@ class ProductModel(ui.core.ABTreeModel): #-- flag overrides --- def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: return True + + def displayData(self, index: QModelIndex) -> any: + column_name = self.column_name(index) + if column_name != "node": + return super().displayData(index) + + row = self.row(index) + node_data = { + "database": row.get("key")[0], + "name": row.get("name"), + "product": row.get("product"), + "unit": row.get("unit"), + "location": row.get("location"), + "type": row.get("type"), + "categories": row.get("categories"), + } + return node_data #-- data overrides --- def decorationData(self, index: QtCore.QModelIndex) -> any: diff --git a/activity_browser/ui/delegates/__init__.py b/activity_browser/ui/delegates/__init__.py index e0fc331ac..3e1cc25bb 100644 --- a/activity_browser/ui/delegates/__init__.py +++ b/activity_browser/ui/delegates/__init__.py @@ -14,6 +14,7 @@ from .date_time import DateTimeDelegate from .property import PropertyDelegate from .amount import AmountDelegate, AbsoluteAmountDelegate +from .node import NodeDelegate __all__ = [ "AmountDelegate", @@ -33,4 +34,5 @@ "NewFormulaDelegate", "DateTimeDelegate", "PropertyDelegate", + "NodeDelegate", ] diff --git a/activity_browser/ui/delegates/node.py b/activity_browser/ui/delegates/node.py new file mode 100644 index 000000000..8f4c26c3b --- /dev/null +++ b/activity_browser/ui/delegates/node.py @@ -0,0 +1,56 @@ +from qtpy import QtCore, QtWidgets, QtGui +from qtpy.QtGui import QFontMetrics, QFont, QPixmap +from qtpy.QtCore import Qt + +from activity_browser.ui.widgets import NodeDetails + + +class NodeDelegate(QtWidgets.QStyledItemDelegate): + """For managing and validating entered float values.""" + + def sizeHint(self, option, index): + if index.data() is None: + return super().sizeHint(option, index) + + # Create a temporary widget to calculate the required size + viewport = self.parent().findChild(QtWidgets.QWidget, "qt_scrollarea_viewport") + is_selected = option.state & QtWidgets.QStyle.StateFlag.State_Selected + node_details = NodeDetails(index.data(), viewport, selected=is_selected) + node_details.setFixedWidth(option.rect.width()) + node_details.adjustSize() + node_details.ensurePolished() + + size = node_details.sizeHint() + node_details.deleteLater() + + return size + + def displayText(self, value, locale): + return f"{value}" + + def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): + if index.data() is None: + super().paint(painter, option, index) + return + + painter.save() + + viewport = self.parent().findChild(QtWidgets.QWidget, "qt_scrollarea_viewport") + is_selected = option.state & QtWidgets.QStyle.StateFlag.State_Selected + node_details = NodeDetails(index.data(), viewport, selected=is_selected) + node_details.resize(option.rect.width(), option.rect.height()) + node_details.ensurePolished() + + # Create high-DPI aware pixmap + device_pixel_ratio = painter.device().devicePixelRatio() + pixmap = QPixmap(node_details.size() * device_pixel_ratio) + pixmap.setDevicePixelRatio(device_pixel_ratio) + pixmap.fill(Qt.transparent) + + # Render directly to pixmap + node_details.render(pixmap, QtCore.QPoint(), QtGui.QRegion(), + QtWidgets.QWidget.DrawChildren) + + painter.drawPixmap(option.rect.topLeft(), pixmap) + painter.restore() + return diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index 9f7856b05..d70194d4a 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -22,3 +22,4 @@ from .tree_view import ABTreeView from .buttons import ABCloseButton, ABMinimizeButton from .tab_widget import ABTabWidget +from .node_details import NodeDetails diff --git a/activity_browser/ui/widgets/buttons.py b/activity_browser/ui/widgets/buttons.py index 6bce9795f..a153cfd66 100644 --- a/activity_browser/ui/widgets/buttons.py +++ b/activity_browser/ui/widgets/buttons.py @@ -12,7 +12,7 @@ def __init__(self, parent=None): self.label = QtWidgets.QLabel("×", self) - self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) + self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Weight.Light)) self.label.setAlignment(Qt.AlignCenter) self.label.setFixedSize(16, 16) self.label.mousePressEvent = lambda event: self.clicked.emit() @@ -42,7 +42,7 @@ def __init__(self, parent=None): self.label = QtWidgets.QLabel("-", self) - self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) + self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Weight.Light)) self.label.setAlignment(Qt.AlignCenter) self.label.setFixedSize(16, 16) self.label.mousePressEvent = lambda event: self.clicked.emit() diff --git a/activity_browser/ui/widgets/node_details.py b/activity_browser/ui/widgets/node_details.py new file mode 100644 index 000000000..f6760bf41 --- /dev/null +++ b/activity_browser/ui/widgets/node_details.py @@ -0,0 +1,138 @@ +from typing import TypedDict + +import pandas as pd + +from PySide6 import QtGui +from qtpy import QtCore, QtWidgets + +from activity_browser.ui import icons + +class NodeData(TypedDict): + database: str + name: str + product: str | None + unit: str + location: str | None + categories: list[str] | None + type: str + + +class NodeDetails(QtWidgets.QFrame): + clicked = QtCore.Signal(dict) + + def __init__(self, node_data: NodeData, parent=None, selected=False): + super().__init__(parent) + + self.node_data = node_data + self.selected = selected + + # Get the icon for this node type + node_type = node_data.get('type', '') + self.icon = self.decorationData(node_type) + + # Set frame shape for proper rendering + self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel) + + # Set cursor to pointer to indicate clickability + self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + + # Set minimum height + self.setMinimumHeight(40) + + # Apply stylesheet with actual color values + # Use the highlight border when selected + border_color = "palette(highlight)" if selected else "palette(mid)" + self.setStyleSheet(f""" + NodeDetails {{ + border: 1px solid {border_color}; + border-radius: 3px; + margin: 2px; + background-color: palette(base); + }} + NodeDetails:hover {{ + border: 1px solid palette(highlight); + }} + """) + + line_height = self.fontMetrics().height() + + layout = QtWidgets.QVBoxLayout(self) + + name = self.node_data.get('product') or self.node_data.get('name') + name_font = self.font() + name_font.setPointSize(10) + name_font.setWeight(QtGui.QFont.Weight.DemiBold) + name_label = QtWidgets.QLabel(name) + name_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + name_label.setFont(name_font) + name_label.setWordWrap(True) + name_label.setFixedHeight(int(line_height * 2.2)) + + producer = self.node_data.get("name") if self.node_data.get("product") else "" + producer_font = self.font() + producer_font.setPointSize(7) + producer_label = QtWidgets.QLabel(producer) + producer_label.setFont(producer_font) + producer_label.setFixedHeight(line_height) + + categories = self.node_data.get("categories", []) + categories = [] if pd.isna(categories) else categories + categories = ", ".join(categories) + categories_font = producer_font + categories_label = QtWidgets.QLabel(categories) + categories_label.setFont(categories_font) + categories_label.setFixedHeight(line_height) + + crumbs = [node_data.get('unit'), node_data.get('location'), node_data.get('database')] + crumbs = [str(crumb) for crumb in crumbs if str(crumb).strip() not in ("nan", "None", "")] + crumbs_text = " | ".join(crumbs) + crumbs_font = self.font() + crumbs_font.setPointSize(6) + crumbs_label = QtWidgets.QLabel(crumbs_text) + crumbs_label.setFont(crumbs_font) + crumbs_label.setFixedHeight(int(line_height * 0.8)) + + layout.addWidget(name_label) + layout.addWidget(producer_label) if producer else layout.addWidget(categories_label) + layout.addWidget(crumbs_label, alignment=QtCore.Qt.AlignmentFlag.AlignRight) + + self.setLayout(layout) + + def decorationData(self, node_type: str) -> QtGui.QIcon: + if node_type == "product": + return icons.qicons.product + if node_type == "waste": + return icons.qicons.waste + if node_type == "processwithreferenceproduct": + return icons.qicons.processproduct + if node_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: + return icons.qicons.biosphere + return icons.qicons.process + + def paintEvent(self, event): + """Paint the frame with icon in background""" + super().paintEvent(event) + + if self.icon and not self.icon.isNull(): + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + + # Set opacity for the background icon + painter.setOpacity(0.1) + + # Calculate icon size and position (right side of the frame) + icon_size = int(self.height() * 0.8) + x = self.width() - icon_size - 10 + y = (self.height() - icon_size) // 2 + + # Draw the icon + pixmap = self.icon.pixmap(icon_size, icon_size) + painter.drawPixmap(x, y, pixmap) + + painter.end() + + def mousePressEvent(self, event): + """Handle mouse click events""" + if event.button() == QtCore.Qt.MouseButton.LeftButton: + self.clicked.emit(self.node_data) + super().mousePressEvent(event) \ No newline at end of file diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py index 13d346f67..28555c67d 100644 --- a/activity_browser/ui/widgets/tree_view.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -2,7 +2,7 @@ from qtpy import QtWidgets, QtCore, QtGui -from activity_browser.ui import delegates, core +from activity_browser.ui import core from .line_edit import ABLineEdit @@ -67,6 +67,8 @@ def __init__(self, pos, view): super().__init__(view) def __init__(self, parent=None): + from activity_browser.ui import delegates + super().__init__(parent) self.setIndentation(10) self.setUniformRowHeights(True) From 73353c9e562197ca7ffb0056aca425c8d72fbe39 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 25 Nov 2025 11:40:43 +0100 Subject: [PATCH 138/267] UI changes for the table_view --- activity_browser/app/panes/database_products.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index a257b94b2..ba3fbbb31 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -155,6 +155,9 @@ def sync(self): self.model.filter("db_p_pane", "`_rank` >= 0") # show all rows by default self.table_view.header().setHidden(self.simple) + self.table_view.viewport().setBackgroundRole(QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) + self.table_view.setFrameShape( + QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) for col in self.model.columns(): if col == "index" or col == "node": From fbd8f2c7074b0d99a184cd8cfa22369b8dbba064 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 25 Nov 2025 13:08:58 +0100 Subject: [PATCH 139/267] Update to node selection tool --- .../app/dialogs/node_select_dialog.py | 160 ++++++++++++------ 1 file changed, 109 insertions(+), 51 deletions(-) diff --git a/activity_browser/app/dialogs/node_select_dialog.py b/activity_browser/app/dialogs/node_select_dialog.py index b59a28c77..d4f91a4c5 100644 --- a/activity_browser/app/dialogs/node_select_dialog.py +++ b/activity_browser/app/dialogs/node_select_dialog.py @@ -1,6 +1,8 @@ -from qtpy import QtWidgets, QtCore +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt +import pandas as pd -from activity_browser.ui import widgets +from activity_browser.ui import widgets, core, delegates from activity_browser.app import metadata, actions @@ -18,37 +20,25 @@ def __init__(self, parent=None): self.setFixedWidth(400) self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.Maximum) - print(self.sizeHint()) - self.edit = widgets.ABLineEdit(self) self.edit.setPlaceholderText("Enter text to search for a node") self.edit.textChangedDebounce.connect(self.on_search) - # Create scroll area for results - self.scroll_area = QtWidgets.QScrollArea(self) - self.scroll_area.setWidgetResizable(True) - self.scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.scroll_area.setFixedHeight(0) # Start with height 0 - - # Container widget for results - self.results_container = QtWidgets.QWidget() - self.results_layout = QtWidgets.QVBoxLayout(self.results_container) - self.results_layout.setContentsMargins(0, 0, 0, 0) - self.results_layout.setSpacing(2) - self.results_layout.addStretch() + # Create model and tree view for results + self.model = NodeSearchModel(parent=self) + self.tree_view = NodeSearchView(self) + self.tree_view.setModel(self.model) - self.scroll_area.setWidget(self.results_container) + self.tree_view.doubleClicked.connect(self.on_node_double_clicked) + self.tree_view.dragStarted.connect(self.on_drag_started) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(5, 0, 5, 0) layout.addWidget(self.edit) - layout.addWidget(self.scroll_area) + layout.addWidget(self.tree_view) self.setLayout(layout) - self.min_height = self.sizeHint().height() - - self.setFixedHeight(self.min_height) + self.setFixedHeight(self.sizeHint().height()) def showEvent(self, event): """Position the dialog 200px higher than default centered position""" @@ -64,45 +54,113 @@ def showEvent(self, event): self.move(x, y) def on_search(self, text: str): - # Clear existing results - while self.results_layout.count() > 1: # Keep the stretch item - item = self.results_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - if not text.strip(): - self.scroll_area.setFixedHeight(0) + # Clear results + self.model.set_dataframe(pd.DataFrame()) + self.tree_view.setFixedHeight(0) self.adjustSize() self.setFixedHeight(self.sizeHint().height()) return - result = metadata.search(text) - result = result[0:10] if len(result) > 10 else result - result.reverse() - result = metadata.dataframe.loc[metadata.dataframe["id"].isin(result)].copy() - result["rank_map"] = result["id"].apply(lambda x: result["id"].tolist().index(x)) - result = result.sort_values(by=["rank_map"]).drop(columns=["rank_map"]) - - # Create NodeResult widgets for each result - if len(result) > 0: - for idx, row in result.iterrows(): - node_data = row.to_dict() - node_widget = widgets.NodeDetails(node_data, self) - node_widget.clicked.connect(self.on_node_selected) - self.results_layout.insertWidget(self.results_layout.count() - 1, node_widget) - # Set scroll area height to show results (max 300px) - self.scroll_area.setFixedHeight(400) + # Search and get results + result_ids = metadata.search(text) + result_ids = result_ids[0:10] if len(result_ids) > 10 else result_ids + result_ids.reverse() + + # Get dataframe with results + result_df = metadata.dataframe.loc[metadata.dataframe["id"].isin(result_ids)].copy() + result_df["rank_map"] = result_df["id"].apply(lambda x: result_ids.index(x)) + result_df = result_df.sort_values(by=["rank_map"]).drop(columns=["rank_map"]) + + # Prepare data for display + result_df["node"] = result_df.apply(lambda row: { + "database": row.get("database"), + "name": row.get("name"), + "product": row.get("product"), + "unit": row.get("unit"), + "location": row.get("location"), + "type": row.get("type"), + "categories": row.get("categories"), + "id": row.get("id"), + "key": row.get("key"), + }, axis=1) + + # Update model with search results + self.model.set_dataframe(result_df[["node"]]) + + # Adjust height based on results + if len(result_df) > 0: + self.tree_view.setFixedHeight(min(400, len(result_df) * 80 + 20)) else: - self.scroll_area.setFixedHeight(0) + self.tree_view.setFixedHeight(0) # Adjust dialog to minimum size self.adjustSize() self.setFixedHeight(self.sizeHint().height()) - def on_node_selected(self, node_data: dict): - """Handle when a node is clicked""" - self.node_selected.emit(node_data) + def on_node_double_clicked(self, index: QtCore.QModelIndex): + """Handle when a node is double-clicked in the tree view""" + if not index.isValid(): + return + + # Get node data from the model + node_data = self.model.get(index, "node") + if node_data: + self.node_selected.emit(node_data) + actions.ActivityOpen.run([node_data.get("id")]) + self.accept() # Close the dialog + + def on_drag_started(self): + """Handle when a drag operation is started""" + self.hide() # Close the dialog + +class NodeSearchModel(core.ABTreeModel): + """Model for displaying search results in the node select dialog.""" + + def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: + return True + + def mimeData(self, indices: list[QtCore.QModelIndex]): + """ + Returns the mime data for the given indices. + + Args: + indices (list[QtCore.QModelIndex]): The indices to get the mime data for. + + Returns: + core.ABMimeData: The mime data. + """ + data = core.ABMimeData() + keys = [index.data().get("key") for index in indices if index.isValid()] + keys = {key for key in keys if isinstance(key, tuple)} + data.setPickleData("application/bw-nodekeylist", list(keys)) + return data + + +class NodeSearchView(widgets.ABTreeView): + """Tree view for displaying node search results.""" + dragStarted: QtCore.SignalInstance = QtCore.Signal() + + defaultColumnDelegates = { + "node": delegates.NodeDelegate, + } + + def __init__(self, parent: NodeSelectDialog): + super().__init__(parent) + self.setSelectionBehavior(widgets.ABTreeView.SelectionBehavior.SelectRows) + self.setSelectionMode(widgets.ABTreeView.SelectionMode.SingleSelection) + self.viewport().setBackgroundRole(QtGui.QPalette.ColorRole.Window) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) + + self.setHeaderHidden(True) + self.setDragEnabled(True) + + self.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + self.setFixedHeight(0) + - actions.ActivityOpen.run([node_data.get("id")]) + def startDrag(self, supportedActions: Qt.DropAction) -> None: + self.dragStarted.emit() + super().startDrag(supportedActions) - self.accept() # Close the dialog From 9d7d9a751342e33e96eb31e31f908634c5214324 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 25 Nov 2025 13:37:58 +0100 Subject: [PATCH 140/267] Search to return dataframes --- .../app/dialogs/node_select_dialog.py | 10 ++---- .../app/panes/database_products.py | 29 ++++++--------- activity_browser/bwutils/metadata/metadata.py | 35 ++++++++++++++----- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/activity_browser/app/dialogs/node_select_dialog.py b/activity_browser/app/dialogs/node_select_dialog.py index d4f91a4c5..97a74c18f 100644 --- a/activity_browser/app/dialogs/node_select_dialog.py +++ b/activity_browser/app/dialogs/node_select_dialog.py @@ -63,14 +63,8 @@ def on_search(self, text: str): return # Search and get results - result_ids = metadata.search(text) - result_ids = result_ids[0:10] if len(result_ids) > 10 else result_ids - result_ids.reverse() - - # Get dataframe with results - result_df = metadata.dataframe.loc[metadata.dataframe["id"].isin(result_ids)].copy() - result_df["rank_map"] = result_df["id"].apply(lambda x: result_ids.index(x)) - result_df = result_df.sort_values(by=["rank_map"]).drop(columns=["rank_map"]) + result_df = metadata.search(text) + result_df = result_df[0:10] if len(result_df) > 10 else result_df # Prepare data for display result_df["node"] = result_df.apply(lambda row: { diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index ba3fbbb31..2a54ac864 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -149,13 +149,12 @@ def sync(self): """ t = time() df = self.build_df() - df.reset_index(drop=True, inplace=True) self.model.set_dataframe(df) - self.model.filter("db_p_pane", "`_rank` >= 0") # show all rows by default self.table_view.header().setHidden(self.simple) - self.table_view.viewport().setBackgroundRole(QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) + self.table_view.viewport().setBackgroundRole( + QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) self.table_view.setFrameShape( QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) @@ -180,12 +179,15 @@ def build_df(self) -> pd.DataFrame: """ t = time() cols = ["name", "key", "processor", "product", "type", "unit", "location", "id", "categories", "properties"] - df = app.metadata.get_database_metadata(self.database.name, cols) + + query = self.search_bar.toPlainText() + if query: + df = app.metadata.search_database(query, self.database.name, cols) + else: + df = app.metadata.get_database_metadata(self.database.name, cols) processors = set(df["processor"].dropna().unique()) df = df.drop(processors, errors="ignore") - - df["_rank"] = 0 df.rename(columns={"id": "_id"}, inplace=True) if not df.properties.isna().all(): @@ -206,11 +208,11 @@ def build_df(self) -> pd.DataFrame: if self.simple: cols += ["node"] cols += [col for col in df.columns if col.startswith("property")] - cols += ["_id", "_rank"] + cols += ["_id"] logger.debug(f"Built DatabaseProductsPane dataframe in {time() - t:.2f} seconds") - return df[cols] + return df[cols].reset_index(drop=True) def on_database_deleted(self, db_name: str): """ @@ -254,16 +256,7 @@ def search(self, query: str): Args: query (str): The search query. """ - if query.startswith("=") or query == "": - self.table_view.setAllFilter(query) - return - - results = app.metadata.search_database(query, database=self.database.name, logging=True) - results.reverse() - rank_map = {res: i for i, res in enumerate(results)} - - self.model.df["_rank"] = self.model.df["_id"].map(rank_map).fillna(-1).astype(int) - self.model.sort("_rank", Qt.SortOrder.DescendingOrder) + self.sync() class ProductView(ui.widgets.ABTreeView): """ diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index d68b75ae6..8b3db273b 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -1,11 +1,12 @@ from typing import Literal, Optional +from loguru import logger import pandas as pd from .fields import all, all_types -class MetaDataStore(): +class MetaDataStore: _instance = None def __new__(cls): @@ -81,7 +82,7 @@ def match(self, **kwargs: dict[str, str]) -> pd.DataFrame: return df - def get_metadata(self, keys: list, columns: list) -> pd.DataFrame: + def get_metadata(self, keys: list, columns: list = None) -> pd.DataFrame: """Return a slice of the dataframe matching row and column identifiers. NOTE: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#deprecate-loc-reindex-listlike @@ -93,18 +94,34 @@ def get_metadata(self, keys: list, columns: list) -> pd.DataFrame: def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFrame: if db_name not in self.databases: - return pd.DataFrame(columns=all) + return pd.DataFrame(columns=columns or all) return self.dataframe.loc[[db_name], columns or all] - def search(self, query: str) -> list[int]: - return self.searcher.search(query) + def search(self, query: str, columns: list = None) -> pd.DataFrame: + if not self.searcher: + logger.warning(f"Attempted to search metadata before searcher was initialized.") + return pd.DataFrame(columns=columns or all) - def search_database(self, query: str, database: Optional[str] = None, return_counter: bool = False, logging: bool = True): - # we do fuzzy search as we re-index results (combining products and activities) for database_products table - # anyway, so including literal results quite literally is a waste of time at this point - return self.searcher.fuzzy_search(query, database=database, return_counter=return_counter, logging=logging) + result = self.searcher.search(query) + df = self.dataframe.loc[self.dataframe["id"].isin(result), columns or all] + df.sort_values(by="id", inplace=True, key=lambda x: x.map({id_: i for i, id_ in enumerate(result)})) + return df + + def search_database(self, query: str, database: str, columns: list = None) -> pd.DataFrame: + if not self.searcher: + logger.warning(f"Attempted to search metadata before searcher was initialized.") + return pd.DataFrame(columns=columns or all) + + result = self.searcher.fuzzy_search(query, database=database) + df = self.dataframe.loc[self.dataframe["id"].isin(result), columns or all] + df.sort_values(by="id", inplace=True, key=lambda x: x.map({id_: i for i, id_ in enumerate(result)})) + return df def auto_complete(self, word: str, context: Optional[set] = None, database: Optional[str] = None): + if not self.searcher: + logger.warning(f"Attempted to search metadata before searcher was initialized.") + return [] + word = self.searcher.clean_text(word) completions = self.searcher.auto_complete(word, context=context, database=database) return completions From 40bce356f207fc23e60eec51d1b7a22d9dd78dab Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 25 Nov 2025 14:21:48 +0100 Subject: [PATCH 141/267] Parameters for search --- activity_browser/bwutils/metadata/metadata.py | 33 +++++++++++++++++-- activity_browser/bwutils/metadata/searcher.py | 2 ++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 8b3db273b..1e98d5dd9 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -102,19 +102,33 @@ def search(self, query: str, columns: list = None) -> pd.DataFrame: logger.warning(f"Attempted to search metadata before searcher was initialized.") return pd.DataFrame(columns=columns or all) + params, query = get_query_parameters(query) result = self.searcher.search(query) - df = self.dataframe.loc[self.dataframe["id"].isin(result), columns or all] - df.sort_values(by="id", inplace=True, key=lambda x: x.map({id_: i for i, id_ in enumerate(result)})) - return df + return self._meta_from_result(params, result, columns) def search_database(self, query: str, database: str, columns: list = None) -> pd.DataFrame: if not self.searcher: logger.warning(f"Attempted to search metadata before searcher was initialized.") return pd.DataFrame(columns=columns or all) + params, query = get_query_parameters(query) result = self.searcher.fuzzy_search(query, database=database) + return self._meta_from_result(params, result, columns) + + def _meta_from_result(self, params: dict, result: list[int], columns: list = None) -> pd.DataFrame: df = self.dataframe.loc[self.dataframe["id"].isin(result), columns or all] df.sort_values(by="id", inplace=True, key=lambda x: x.map({id_: i for i, id_ in enumerate(result)})) + + extra_query = " & ".join( + [ + f"`{key}`.astype('str').str.contains('{value}', False)" + for key, value in params.items() + if key in df.columns + ] + ) + if extra_query: + df = df.query(extra_query) + return df def auto_complete(self, word: str, context: Optional[set] = None, database: Optional[str] = None): @@ -125,3 +139,16 @@ def auto_complete(self, word: str, context: Optional[set] = None, database: Opti word = self.searcher.clean_text(word) completions = self.searcher.auto_complete(word, context=context, database=database) return completions + +def get_query_parameters(query: str) -> tuple[dict[str, str], str]: + """Extract key-value pairs from a query string of the form 'key1:value1 key2:value2'.""" + params = {} + tokens = query.split() + clean_query = [] + for token in tokens: + if ':' in token: + key, value = token.split(':', 1) + params[key] = value + else: + clean_query.append(token) + return params, ' '.join(clean_query) diff --git a/activity_browser/bwutils/metadata/searcher.py b/activity_browser/bwutils/metadata/searcher.py index 54ef3fcf3..ab2b235f3 100644 --- a/activity_browser/bwutils/metadata/searcher.py +++ b/activity_browser/bwutils/metadata/searcher.py @@ -322,6 +322,8 @@ def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter if len(text) == 0: log.debug(f"Empty search, returned all items") + if database: + return self.df.loc[self.df["database"] == database].index.to_list() return self.df.index.to_list() # DATABASE SPECIFIC get the set of ids that is in this database From 95a3210c6b381bc2ef010bbfcd3b63b677e1ba7a Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 25 Nov 2025 14:39:21 +0100 Subject: [PATCH 142/267] Drag and drop to ProductViews --- .../app/panes/database_products.py | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 2a54ac864..f322edd76 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -43,6 +43,7 @@ def __init__(self, parent, db_name: str): self.name = "database_products_pane_" + db_name super().__init__(parent) + self.database = bd.Database(db_name) self.title = db_name self.simple = True @@ -74,7 +75,7 @@ def __init__(self, parent, db_name: str): self.loading_label.setStyleSheet("color: gray; padding: 10px;") # Create simple/detailed view toggle - self.view_toggle = QtWidgets.QCheckBox("Detailed View") + self.view_toggle = QtWidgets.QCheckBox("Details") self.view_toggle.setChecked(not self.simple) self.view_toggle.setToolTip("Toggle between simple and detailed view") @@ -258,6 +259,7 @@ def search(self, query: str): """ self.sync() + class ProductView(ui.widgets.ABTreeView): """ A view that displays the products in a tree structure. @@ -330,7 +332,8 @@ def __init__(self, parent: DatabaseProductsPane, db_name: str): super().__init__(parent) self.setSortingEnabled(True) self.setDragEnabled(True) - self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DragOnly) + self.setAcceptDrops(True) + self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DragDrop) self.setSelectionBehavior(ui.widgets.ABTreeView.SelectionBehavior.SelectRows) self.setSelectionMode(ui.widgets.ABTreeView.SelectionMode.ExtendedSelection) @@ -361,6 +364,53 @@ def mouseDoubleClickEvent(self, event) -> None: if self.selected_activities: app.actions.ActivityOpen.run(self.selected_activities) + def dragEnterEvent(self, event): + """ + Handles the drag enter event. + + Args: + event: The drag enter event. + """ + if event.source() == self: + return + + if database_is_locked(self.db_name): + return + + if event.mimeData().hasFormat("application/bw-nodekeylist"): + self.overlay = widgets.ABDropOverlay(self) + self.overlay.show() + event.accept() + + def dragMoveEvent(self, event): + pass + + def dragLeaveEvent(self, event): + """ + Handles the drag leave event. + + Args: + event: The drag leave event. + """ + # Reset the palette on drag leave + self.overlay.deleteLater() + + def dropEvent(self, event): + """ + Handles the drop event. + + Args: + event: The drop event. + """ + logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") + # Reset the palette on drop + self.overlay.deleteLater() + + keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") + keys = list(set(keys)) + + app.actions.ActivityDuplicateToDB.run(keys, self.db_name) + @property def selected_products(self) -> list[tuple]: """ From 2cf0ea302711a0494be269282800a582bd2aa0a8 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 25 Nov 2025 15:25:17 +0100 Subject: [PATCH 143/267] Keyboard actions --- .../activity/activity_duplicate_to_db.py | 2 +- .../app/panes/database_products.py | 77 +++++++++++++++++-- activity_browser/ui/widgets/drop_overlay.py | 5 +- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/activity_browser/app/actions/activity/activity_duplicate_to_db.py b/activity_browser/app/actions/activity/activity_duplicate_to_db.py index 349cff6e1..d677590a1 100644 --- a/activity_browser/app/actions/activity/activity_duplicate_to_db.py +++ b/activity_browser/app/actions/activity/activity_duplicate_to_db.py @@ -47,7 +47,7 @@ def run(cls, nodes: List[tuple | int | bd.Node], to_db_name: str = None): elif from_db_backend == "functional_sqlite" and to_db_backend == "sqlite": new_nodes = cls.duplicate_functional_sqlite_to_sqlite(nodes, to_db_name) else: - raise NotImplementedError(f"Moving from {from_db_backend} to {to_db_backend} is not supported.") + raise NotImplementedError(f"Copying from {from_db_backend} to {to_db_backend} is not supported.") ActivityOpen.run(new_nodes) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index f322edd76..97357056f 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -1,17 +1,15 @@ -from PySide6.QtCore import QModelIndex from loguru import logger from time import time import pandas as pd from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt +from qtpy.QtCore import Qt, QModelIndex import bw2data as bd -from activity_browser import app, ui, app -from activity_browser.settings import project_settings +from activity_browser import ui, app from activity_browser.ui import core, widgets, delegates, icons -from activity_browser.bwutils.commontasks import database_is_locked, database_is_legacy +from activity_browser.bwutils.commontasks import database_is_locked, database_is_legacy, is_node_biosphere NODETYPES = { @@ -338,6 +336,7 @@ def __init__(self, parent: DatabaseProductsPane, db_name: str): self.setSelectionMode(ui.widgets.ABTreeView.SelectionMode.ExtendedSelection) self.db_name = db_name + self.pane = parent self.propertyDelegate = delegates.PropertyDelegate(self) @@ -364,6 +363,54 @@ def mouseDoubleClickEvent(self, event) -> None: if self.selected_activities: app.actions.ActivityOpen.run(self.selected_activities) + def keyPressEvent(self, event) -> None: + """ + Handles key press events. Specifically handles Ctrl+C to copy selected data. + + Args: + event: The key press event. + """ + if event.modifiers() & Qt.KeyboardModifier.ControlModifier: + if event.key() == Qt.Key.Key_C: # Copy + self.copy_selection_to_clipboard() + return + if event.key() == Qt.Key.Key_V: + self.copy_from_clipboard() + if event.key() == Qt.Key.Key_A: # Select All + self.selectAll() + return + if event.key() == Qt.Key.Key_F: # Find + self.pane.search_bar.setFocus() + return + if event.key() == Qt.Key.Key_Delete: + if database_is_locked(self.db_name): + return + if self.selected_products: + app.actions.ActivityDelete.run(self.selected_products) + return + + super().keyPressEvent(event) + + def copy_selection_to_clipboard(self): + selection = self.selectedIndexes() + mime_data = self.model().mimeData(selection) + + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setMimeData(mime_data) + + def copy_from_clipboard(self): + if database_is_locked(self.db_name): + return + + clipboard = QtWidgets.QApplication.clipboard() + mime_data = clipboard.mimeData() + + if mime_data.hasFormat("application/bw-nodekeylist"): + keys: list = mime_data.retrievePickleData("application/bw-nodekeylist") + keys = list(set(keys)) + + app.actions.ActivityDuplicateToDB.run(keys, self.db_name) + def dragEnterEvent(self, event): """ Handles the drag enter event. @@ -378,7 +425,12 @@ def dragEnterEvent(self, event): return if event.mimeData().hasFormat("application/bw-nodekeylist"): - self.overlay = widgets.ABDropOverlay(self) + keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") + + if any(is_node_biosphere(key) for key in keys): + return + + self.overlay = widgets.ABDropOverlay(self, text="Drop here to duplicate to this database") self.overlay.show() event.accept() @@ -508,4 +560,17 @@ def mimeData(self, indices: list[QtCore.QModelIndex]): keys.update(self.values_from_indices("processor", indices)) keys = {key for key in keys if isinstance(key, tuple)} data.setPickleData("application/bw-nodekeylist", list(keys)) + + # Add text data for Excel/external apps + # Get selected rows and build tab-separated text + rows = [self.row(i) for i in indices] + columns = [c for c in self.columns() if c not in ["index", "node"]] + text_lines = ["\t".join(columns)] # Header line + + for row in rows: + # Select relevant columns for export + text_lines.append("\t".join(str(row.get(col, "")) for col in columns)) + + data.setText("\n".join(text_lines)) + return data diff --git a/activity_browser/ui/widgets/drop_overlay.py b/activity_browser/ui/widgets/drop_overlay.py index 326b2e2d8..3c702c926 100644 --- a/activity_browser/ui/widgets/drop_overlay.py +++ b/activity_browser/ui/widgets/drop_overlay.py @@ -3,13 +3,14 @@ class ABDropOverlay(QtWidgets.QWidget): - def __init__(self, parent=None): + def __init__(self, parent=None, text="Drop here to create new exchanges"): super().__init__(parent) self.setAttribute(Qt.WA_TransparentForMouseEvents) self.setAttribute(Qt.WA_NoSystemBackground) self.setAttribute(Qt.WA_TranslucentBackground) self.setAutoFillBackground(False) self.resize(parent.size()) + self.text = text def paintEvent(self, event): painter = QtGui.QPainter(self) @@ -21,4 +22,4 @@ def paintEvent(self, event): font.setBold(True) painter.setFont(font) - painter.drawText(self.rect(), Qt.AlignCenter, "Drop here to create new exchanges") + painter.drawText(self.rect(), Qt.AlignCenter, self.text) From 5c3da841c5dde308058c68603d79db3dae81dba4 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 27 Nov 2025 11:13:20 +0100 Subject: [PATCH 144/267] Move from node to card delegate --- .../app/dialogs/import_preview_dialog.py | 97 ---------- .../dialogs/import_preview_dialog/__init__.py | 1 + .../import_preview_dialog.py | 27 +++ .../dialogs/import_preview_dialog/node_tab.py | 138 ++++++++++++++ .../app/dialogs/node_select_dialog.py | 86 ++++++--- .../app/panes/database_products.py | 48 +++-- activity_browser/bwutils/metadata/metadata.py | 6 + activity_browser/ui/core/tree_model.py | 7 +- activity_browser/ui/delegates/__init__.py | 4 +- activity_browser/ui/delegates/card.py | 169 ++++++++++++++++++ activity_browser/ui/delegates/node.py | 56 ------ activity_browser/ui/widgets/__init__.py | 3 +- activity_browser/ui/widgets/node_details.py | 138 -------------- 13 files changed, 449 insertions(+), 331 deletions(-) delete mode 100644 activity_browser/app/dialogs/import_preview_dialog.py create mode 100644 activity_browser/app/dialogs/import_preview_dialog/__init__.py create mode 100644 activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py create mode 100644 activity_browser/app/dialogs/import_preview_dialog/node_tab.py create mode 100644 activity_browser/ui/delegates/card.py delete mode 100644 activity_browser/ui/delegates/node.py delete mode 100644 activity_browser/ui/widgets/node_details.py diff --git a/activity_browser/app/dialogs/import_preview_dialog.py b/activity_browser/app/dialogs/import_preview_dialog.py deleted file mode 100644 index 8ca72e1dc..000000000 --- a/activity_browser/app/dialogs/import_preview_dialog.py +++ /dev/null @@ -1,97 +0,0 @@ -from qtpy import QtWidgets, QtCore, QtGui - -import pandas as pd - -from bw2io.importers.base_lci import LCIImporter - -from activity_browser.ui import widgets, core - - -class ImportPreviewDialog(QtWidgets.QDialog): - standardNodeColumns = ["type", "name", "exchanges", "unlinked_exchanges", "location", "unit", "categories", "code", "database"] - standardEdgeColumns = ["type", "amount", "unit", "input", "name", "location", "database", "formula"] - - def __init__(self, importer: LCIImporter, parent=None): - super().__init__(parent) - self.setWindowTitle("Import Preview") - self.resize(600, 400) - - self.importer = importer - - layout = QtWidgets.QVBoxLayout(self) - - node_df = pd.DataFrame(importer.data) - node_df["unlinked_exchanges"] = node_df["exchanges"].apply( - lambda x: sum(1 for ex in x if not ex.get("input")) if isinstance(x, list) else 0 - ) - node_df["exchanges"] = node_df["exchanges"].apply(lambda x: len(x) if isinstance(x, list) else 0) - - node_df = node_df[ - [col for col in self.standardNodeColumns if col in node_df.columns] + - [col for col in node_df.columns if col not in self.standardNodeColumns] - ] - node_df["_importer_index"] = range(len(node_df)) - - self.node_model = core.ABTreeModel(node_df) - self.node_view = widgets.ABTreeView() - self.node_view.setModel(self.node_model) - - self.exchanges_model = ImportPreviewExchangeModel(importer, self) - self.exchanges_view = widgets.ABTreeView() - self.exchanges_view.setModel(self.exchanges_model) - self.exchanges_view.setHidden(True) - - self.node_view.clicked.connect(self.on_node_selected) - - button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Ok) - button_box.accepted.connect(self.accept) - - layout.addWidget(self.node_view) - layout.addWidget(self.exchanges_view) - - layout.addWidget(button_box) - - def on_node_selected(self, index: QtCore.QModelIndex): - importer_index = self.node_model.get(index, "_importer_index") - exchanges = self.importer.data[importer_index].get('exchanges', []) - - if not exchanges: - self.exchanges_view.setHidden(True) - return - self.exchanges_view.setHidden(False) - - df = pd.DataFrame(exchanges) - df["input"] = df.get("input", None) - - df = df[ - [col for col in self.standardEdgeColumns if col in df.columns] + - [col for col in df.columns if col not in self.standardEdgeColumns] - ] - - self.exchanges_model.set_dataframe(df) - - -class ImportPreviewExchangeModel(core.ABTreeModel): - - def __init__(self, importer, parent=None): - super().__init__(parent=parent) - self.importer = importer - - def indexEditable(self, index: QtCore.QModelIndex) -> bool: - if self.column_name(index) == "input": - return True - return super().indexEditable(index) - - def displayData(self, index: QtCore.QModelIndex) -> any: - data = super().displayData(index) - - if self.column_name(index) == "input" and not data: - return "" - return data - - def fontData(self, index: QtCore.QModelIndex) -> any: - if self.get(index, "type") == "production": - font = QtGui.QFont() - font.setBold(True) - return font - return super().fontData(index) diff --git a/activity_browser/app/dialogs/import_preview_dialog/__init__.py b/activity_browser/app/dialogs/import_preview_dialog/__init__.py new file mode 100644 index 000000000..885add2a0 --- /dev/null +++ b/activity_browser/app/dialogs/import_preview_dialog/__init__.py @@ -0,0 +1 @@ +from .import_preview_dialog import ImportPreviewDialog \ No newline at end of file diff --git a/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py b/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py new file mode 100644 index 000000000..d6a5124c3 --- /dev/null +++ b/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py @@ -0,0 +1,27 @@ +from qtpy import QtWidgets, QtCore, QtGui + +import pandas as pd + +from bw2io.importers.base_lci import LCIImporter + +from activity_browser.ui import widgets, core + +from .node_tab import ImportPreviewNodeTab + + +class ImportPreviewDialog(QtWidgets.QDialog): + def __init__(self, importer: LCIImporter, parent=None): + super().__init__(parent) + self.setWindowTitle("Import Preview") + self.resize(600, 400) + + self.importer = importer + self.tabs = QtWidgets.QTabWidget(self) + + self.node_tab = ImportPreviewNodeTab(importer, self) + + self.tabs.addTab(self.node_tab, "Nodes") + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.tabs) + self.setLayout(layout) diff --git a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py new file mode 100644 index 000000000..ceb886726 --- /dev/null +++ b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py @@ -0,0 +1,138 @@ +from PySide6.QtCore import QModelIndex +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt + +import pandas as pd + +from bw2io.importers.base_lci import LCIImporter + +from activity_browser.ui import widgets, core, delegates, icons + + +class ImportPreviewNodeTab(QtWidgets.QWidget): + standardNodeColumns = ["type", "name", "exchanges", "unlinked_exchanges", "location", "unit", "categories", "code", + "database"] + standardEdgeColumns = ["type", "amount", "unit", "input", "name", "location", "database", "formula"] + + def __init__(self, importer: LCIImporter, parent=None): + super().__init__(parent) + self.importer = importer + self.simple = True + + layout = QtWidgets.QVBoxLayout(self) + + self.node_model = ImportPreviewNodeModel(parent=self) + self.node_model.set_dataframe(self.build_df()) + + self.node_view = ImportPreviewNodeView(parent=self) + self.node_view.setModel(self.node_model) + + # Create simple/detailed view toggle + self.view_toggle = QtWidgets.QCheckBox("Details") + self.view_toggle.setChecked(not self.simple) + self.view_toggle.setToolTip("Toggle between simple and detailed view") + self.view_toggle.checkStateChanged.connect(self.on_mode_switch) + + # Create top bar with toggle + top_bar = QtWidgets.QHBoxLayout() + top_bar.addStretch() + top_bar.addWidget(self.view_toggle) + + layout.addLayout(top_bar) + layout.addWidget(self.node_view) + + self.sync() + + def sync(self): + """Synchronize the view based on simple/detailed mode.""" + self.node_view.header().setHidden(self.simple) + self.node_view.viewport().setBackgroundRole( + QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) + self.node_view.setFrameShape( + QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) + + df = self.node_model.df.copy() + if self.simple and "_node" in df.columns: + df.rename(columns={"_node": "node"}, inplace=True) + elif not self.simple and "node" in df.columns: + df.rename(columns={"node": "_node"}, inplace=True) + self.node_model.set_dataframe(df) + + for col in self.node_model.columns(): + if col == "index": + continue + index = self.node_model.columns().index(col) + + hidden = (self.simple and not col == "node") or (not self.simple and col == "node") + self.node_view.setColumnHidden(index, hidden) + + def build_df(self): + node_df = pd.DataFrame(self.importer.data) + for col in [col for col in self.standardNodeColumns if col not in node_df.columns]: + node_df[col] = None + + node_df["_exchanges"] = node_df["exchanges"] + node_df["unlinked_exchanges"] = node_df["exchanges"].apply( + lambda x: sum(1 for ex in x if not ex.get("input")) if isinstance(x, list) else 0 + ) + node_df["exchanges"] = node_df["exchanges"].apply(lambda x: len(x) if isinstance(x, list) else 0) + + node_df = node_df[ + self.standardNodeColumns + + [col for col in node_df.columns if col not in self.standardNodeColumns] + ] + node_df["_importer_index"] = range(len(node_df)) + + node_df["node"] = None + + return node_df + + def on_mode_switch(self, check: Qt.CheckState): + """Handle the mode switch between simple and detailed view.""" + self.simple = check == Qt.CheckState.Unchecked + self.sync() + + +class ImportPreviewNodeView(widgets.ABTreeView): + """View for displaying import preview nodes.""" + + defaultColumnDelegates = { + "node": delegates.CardDelegate, + } + + +class ImportPreviewNodeModel(core.ABTreeModel): + """Model for import preview nodes with node delegate support.""" + + def displayData(self, index: QModelIndex) -> any: + if not index.isValid(): + return None + + column_name = self.columns()[index.column()] + if not column_name == "node": + return super().displayData(index) + + row_data = self.row(index) + + return { + "title": row_data.get("name"), + "subtitle": f"{row_data.get('type').capitalize()} in {row_data.get('database')}", + "categories": row_data.get("categories") or [], + } + + def decorationData(self, index: QModelIndex) -> QtGui.QIcon: + if not index.isValid(): + return icons.qicons.empty + + node_type = self.get(index, "type") + + if node_type == "product": + return icons.qicons.product + if node_type == "waste": + return icons.qicons.waste + if node_type == "processwithreferenceproduct": + return icons.qicons.processproduct + if node_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: + return icons.qicons.biosphere + return icons.qicons.process + diff --git a/activity_browser/app/dialogs/node_select_dialog.py b/activity_browser/app/dialogs/node_select_dialog.py index 97a74c18f..2f2ac0a5f 100644 --- a/activity_browser/app/dialogs/node_select_dialog.py +++ b/activity_browser/app/dialogs/node_select_dialog.py @@ -2,7 +2,7 @@ from qtpy.QtCore import Qt import pandas as pd -from activity_browser.ui import widgets, core, delegates +from activity_browser.ui import widgets, core, delegates, icons from activity_browser.app import metadata, actions @@ -66,21 +66,11 @@ def on_search(self, text: str): result_df = metadata.search(text) result_df = result_df[0:10] if len(result_df) > 10 else result_df - # Prepare data for display - result_df["node"] = result_df.apply(lambda row: { - "database": row.get("database"), - "name": row.get("name"), - "product": row.get("product"), - "unit": row.get("unit"), - "location": row.get("location"), - "type": row.get("type"), - "categories": row.get("categories"), - "id": row.get("id"), - "key": row.get("key"), - }, axis=1) + # Add a placeholder "node" column for the CardDelegate + result_df["node"] = None # Update model with search results - self.model.set_dataframe(result_df[["node"]]) + self.model.set_dataframe(result_df) # Adjust height based on results if len(result_df) > 0: @@ -98,10 +88,10 @@ def on_node_double_clicked(self, index: QtCore.QModelIndex): return # Get node data from the model - node_data = self.model.get(index, "node") - if node_data: - self.node_selected.emit(node_data) - actions.ActivityOpen.run([node_data.get("id")]) + node_id = self.model.get(index, "id") + if node_id: + self.node_selected.emit(node_id) + actions.ActivityOpen.run([node_id]) self.accept() # Close the dialog def on_drag_started(self): @@ -111,9 +101,65 @@ def on_drag_started(self): class NodeSearchModel(core.ABTreeModel): """Model for displaying search results in the node select dialog.""" + def columns(self) -> list[str]: + return ["index", "node"] + def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: return True + def displayData(self, index: QtCore.QModelIndex) -> any: + if not index.isValid(): + return None + + column_name = self.columns()[index.column()] + if not column_name == "node": + return super().displayData(index) + + row_data = self.row(index) + row_data.dropna(inplace=True) + + # Get the product or name for title + title = row_data.get("product") or row_data.get("name") + + # Build subtitle with type and database + if row_data.get("categories"): + subtitle = ", ".join([str(cat) for cat in row_data.get("categories")]) + elif row_data.get("product"): + subtitle = row_data.get("name") + else: + subtitle = "" + + # Build categories list from unit, location + categories = [] + if row_data.get("unit"): + categories.append(str(row_data.get("unit"))) + if row_data.get("location"): + categories.append(str(row_data.get("location"))) + if row_data.get("database"): + categories.append(str(row_data.get("database"))) + + return { + "title": title, + "subtitle": subtitle, + "categories": categories if categories else None, + } + + def decorationData(self, index: QtCore.QModelIndex) -> QtGui.QIcon: + if not index.isValid(): + return icons.qicons.empty + + node_type = self.get(index, "type") + + if node_type == "product": + return icons.qicons.product + if node_type == "waste": + return icons.qicons.waste + if node_type == "processwithreferenceproduct": + return icons.qicons.processproduct + if node_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: + return icons.qicons.biosphere + return icons.qicons.process + def mimeData(self, indices: list[QtCore.QModelIndex]): """ Returns the mime data for the given indices. @@ -125,7 +171,7 @@ def mimeData(self, indices: list[QtCore.QModelIndex]): core.ABMimeData: The mime data. """ data = core.ABMimeData() - keys = [index.data().get("key") for index in indices if index.isValid()] + keys = [self.row(index).get("key") for index in indices if index.isValid()] keys = {key for key in keys if isinstance(key, tuple)} data.setPickleData("application/bw-nodekeylist", list(keys)) return data @@ -136,7 +182,7 @@ class NodeSearchView(widgets.ABTreeView): dragStarted: QtCore.SignalInstance = QtCore.Signal() defaultColumnDelegates = { - "node": delegates.NodeDelegate, + "node": delegates.CardDelegate, } def __init__(self, parent: NodeSelectDialog): diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 97357056f..0653d1e4b 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -269,7 +269,7 @@ class ProductView(ui.widgets.ABTreeView): "categories": delegates.ListDelegate, "key": delegates.StringDelegate, "processor": delegates.StringDelegate, - "node": delegates.NodeDelegate, + "node": delegates.CardDelegate, } class ContextMenu(ui.widgets.ABMenu): @@ -501,23 +501,47 @@ def displayData(self, index: QModelIndex) -> any: return super().displayData(index) row = self.row(index) - node_data = { - "database": row.get("key")[0], - "name": row.get("name"), - "product": row.get("product"), - "unit": row.get("unit"), - "location": row.get("location"), - "type": row.get("type"), - "categories": row.get("categories"), + + # Get the product or name for title + title = row.get("product") or row.get("name") + + # Build subtitle with name (if product exists) or type + subtitle_parts = [] + if row.get("product") and row.get("name"): + # If there's both product and name, show name as subtitle + subtitle_parts.append(row.get("name")) + elif row.get("type"): + # Otherwise show type + subtitle_parts.append(row.get("type").capitalize()) + + subtitle = " | ".join(subtitle_parts) if subtitle_parts else None + + # Build categories list from unit, location, database + categories = [] + if row.get("unit"): + categories.append(str(row.get("unit"))) + if row.get("location"): + categories.append(str(row.get("location"))) + if row.get("key") and isinstance(row.get("key"), tuple): + categories.append(str(row.get("key")[0])) # database name + + # Add actual categories if they exist + node_categories = row.get("categories") + if node_categories and isinstance(node_categories, (list, tuple)): + categories.extend([str(cat) for cat in node_categories if str(cat).strip()]) + + return { + "title": title, + "subtitle": subtitle, + "categories": categories if categories else None, } - return node_data - + #-- data overrides --- def decorationData(self, index: QtCore.QModelIndex) -> any: column_name = self.column_name(index) node_type = self.get(index, "type") - if column_name not in ["name", "product"]: + if column_name not in ["name", "product", "node"]: return None if column_name == "product" and node_type in ["product", "processwithreferenceproduct"]: return icons.qicons.product diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 1e98d5dd9..f27f879df 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -43,6 +43,12 @@ def dataframe(self, df: pd.DataFrame) -> None: # Ensure all expected columns are present, in the correct order, and with the correct types df = df.reindex(columns=all)[all].astype(all_types) + # No NaN values in object columns, use None instead + for col, col_type in all_types.items(): + if col_type != object: + continue + df[col] = df[col].where(df[col].notnull(), None) + # Set the internal dataframe self._dataframe = df diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index f3d3c8800..ee21850b7 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -200,10 +200,9 @@ def displayData(self, index: QModelIndex) -> any: if index.column() == 0: return None # leaf node tree column is empty - # Use the pre-computed df_position for O(1) iloc access - col_idx = index.column() - 1 # Adjust for tree column - if col_idx < 0 or col_idx >= len(self.df.columns): - return None + # Get the pandas column index (disregard hidden columns) + col_name = self.columns()[index.column()] + col_idx = self.df.columns.get_loc(col_name) val = self.df.iat[node.df_position, col_idx] diff --git a/activity_browser/ui/delegates/__init__.py b/activity_browser/ui/delegates/__init__.py index 3e1cc25bb..9839d0548 100644 --- a/activity_browser/ui/delegates/__init__.py +++ b/activity_browser/ui/delegates/__init__.py @@ -14,7 +14,7 @@ from .date_time import DateTimeDelegate from .property import PropertyDelegate from .amount import AmountDelegate, AbsoluteAmountDelegate -from .node import NodeDelegate +from .card import CardDelegate __all__ = [ "AmountDelegate", @@ -34,5 +34,5 @@ "NewFormulaDelegate", "DateTimeDelegate", "PropertyDelegate", - "NodeDelegate", + "CardDelegate", ] diff --git a/activity_browser/ui/delegates/card.py b/activity_browser/ui/delegates/card.py new file mode 100644 index 000000000..a6c3d4a71 --- /dev/null +++ b/activity_browser/ui/delegates/card.py @@ -0,0 +1,169 @@ +from typing import TypedDict + +from qtpy import QtCore, QtWidgets, QtGui +from qtpy.QtCore import Qt + + +class CardData(TypedDict): + title: str + subtitle: str | None + categories: list[str] | None + icon: QtGui.QIcon | None + + +class CardDelegate(QtWidgets.QStyledItemDelegate): + """Delegate for rendering card-like items with title, subtitle, categories and background icon.""" + + PADDING = 8 + MARGIN = 2 + TITLE_LINES = 2 + ICON_OPACITY = 0.1 + + def sizeHint(self, option, index): + if index.data() is None: + return super().sizeHint(option, index) + + card_data = index.data() + + # Calculate text heights + fm = option.fontMetrics + line_height = fm.height() + + # Title (2 lines, larger font) + title_height = int(line_height * 1.3 * self.TITLE_LINES) + 5 # 1.3x for larger font + + # Subtitle + subtitle_height = int(line_height * 0.9) # 0.9x for smaller font + + # Categories + categories_height = 7 + int(line_height * 0.8) + + # Total height with padding + total_height = (self.PADDING * 2 + self.MARGIN * 2 + + title_height + subtitle_height + categories_height) + + return QtCore.QSize(option.rect.width(), max(total_height, 40)) + + def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): + if index.data() is None: + super().paint(painter, option, index) + return + + painter.save() + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + + card_data = index.data() + is_selected = option.state & QtWidgets.QStyle.StateFlag.State_Selected + + # Draw background and border + rect = option.rect.adjusted(self.MARGIN, self.MARGIN, -self.MARGIN, -self.MARGIN) + + # Background + painter.fillRect(rect, option.palette.base()) + + # Border + border_color = option.palette.highlight() if is_selected else option.palette.mid() + painter.setPen(QtGui.QPen(border_color, 1)) + painter.drawRoundedRect(rect, 3, 3) + + # Draw background icon + icon = index.data(Qt.ItemDataRole.DecorationRole) + icon_size = 0 + if icon and not icon.isNull(): + painter.setOpacity(self.ICON_OPACITY) + icon_size = int(rect.height() * 0.8) + icon_x = rect.right() - icon_size - 10 + icon_y = rect.top() + (rect.height() - icon_size) // 2 + icon.paint(painter, icon_x, icon_y, icon_size, icon_size) + painter.setOpacity(1.0) + + # Setup text area + text_rect = rect.adjusted(self.PADDING, self.PADDING, -self.PADDING, -self.PADDING) + y = text_rect.top() + + # Draw title (bold, larger, 2 lines) + title = card_data.get('title', '') + title_font = option.font + title_font.setPointSize(int(option.font.pointSize() * 1.3)) + title_font.setWeight(QtGui.QFont.Weight.DemiBold) + painter.setFont(title_font) + painter.setPen(option.palette.text().color()) + + title_fm = QtGui.QFontMetrics(title_font) + title_height = 5 + title_fm.height() * self.TITLE_LINES + title_rect = QtCore.QRect(text_rect.left(), y, text_rect.width(), title_height) + + # Elide title text if it's too long for 2 lines + title_text = str(title) + max_width = title_rect.width() + + # Split into words and fit within 2 lines with eliding + words = title_text.split() + line1_words = [] + line2_words = [] + current_line = line1_words + + for word in words: + test_text = " ".join(current_line + [word]) + if title_fm.horizontalAdvance(test_text) <= max_width: + current_line.append(word) + elif current_line is line1_words and len(line2_words) == 0: + # Move to second line + current_line = line2_words + current_line.append(word) + else: + # Need to elide + break + + line1_text = " ".join(line1_words) + line2_text = " ".join(line2_words) + + # If there are remaining words, elide the second line + if len(line1_words) + len(line2_words) < len(words): + line2_text = title_fm.elidedText(title_text if not line1_text else " ".join(words[len(line1_words):]), + Qt.TextElideMode.ElideRight, max_width) + + # Draw title lines + painter.drawText(title_rect.left(), title_rect.top() + title_fm.ascent(), line1_text) + if line2_text: + painter.drawText(title_rect.left(), title_rect.top() + title_fm.ascent() + title_fm.height(), line2_text) + + y += title_height + + # Draw subtitle (smaller) + subtitle = card_data.get('subtitle', '') + if subtitle: + subtitle_font: QtGui.QFont = option.font + subtitle_font.setPointSize(int(option.font.pointSize() * 0.9)) + subtitle_font.setWeight(QtGui.QFont.Weight.Light) + painter.setFont(subtitle_font) + + subtitle_fm = QtGui.QFontMetrics(subtitle_font) + subtitle_height = subtitle_fm.height() + subtitle_rect = QtCore.QRect(text_rect.left(), y, text_rect.width(), subtitle_height) + + # Elide subtitle if too long + subtitle_text = subtitle_fm.elidedText(str(subtitle), Qt.TextElideMode.ElideRight, subtitle_rect.width()) + painter.drawText(subtitle_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, subtitle_text) + y += subtitle_height + + # Draw categories (pipe separated, bottom right) + categories = card_data.get('categories', []) + if categories and isinstance(categories, (list, tuple)): + categories_text = " | ".join(str(cat) for cat in categories) + categories_font = option.font + categories_font.setPointSize(int(option.font.pointSize() * 0.8)) + painter.setFont(categories_font) + + categories_fm = QtGui.QFontMetrics(categories_font) + categories_height = categories_fm.height() + categories_rect = QtCore.QRect(text_rect.left(), text_rect.bottom() - categories_height, + text_rect.width(), categories_height) + + # Elide categories if too long + categories_text_elided = categories_fm.elidedText(categories_text, Qt.TextElideMode.ElideRight, categories_rect.width()) + painter.drawText(categories_rect, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, categories_text_elided) + + painter.restore() + + diff --git a/activity_browser/ui/delegates/node.py b/activity_browser/ui/delegates/node.py deleted file mode 100644 index 8f4c26c3b..000000000 --- a/activity_browser/ui/delegates/node.py +++ /dev/null @@ -1,56 +0,0 @@ -from qtpy import QtCore, QtWidgets, QtGui -from qtpy.QtGui import QFontMetrics, QFont, QPixmap -from qtpy.QtCore import Qt - -from activity_browser.ui.widgets import NodeDetails - - -class NodeDelegate(QtWidgets.QStyledItemDelegate): - """For managing and validating entered float values.""" - - def sizeHint(self, option, index): - if index.data() is None: - return super().sizeHint(option, index) - - # Create a temporary widget to calculate the required size - viewport = self.parent().findChild(QtWidgets.QWidget, "qt_scrollarea_viewport") - is_selected = option.state & QtWidgets.QStyle.StateFlag.State_Selected - node_details = NodeDetails(index.data(), viewport, selected=is_selected) - node_details.setFixedWidth(option.rect.width()) - node_details.adjustSize() - node_details.ensurePolished() - - size = node_details.sizeHint() - node_details.deleteLater() - - return size - - def displayText(self, value, locale): - return f"{value}" - - def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): - if index.data() is None: - super().paint(painter, option, index) - return - - painter.save() - - viewport = self.parent().findChild(QtWidgets.QWidget, "qt_scrollarea_viewport") - is_selected = option.state & QtWidgets.QStyle.StateFlag.State_Selected - node_details = NodeDetails(index.data(), viewport, selected=is_selected) - node_details.resize(option.rect.width(), option.rect.height()) - node_details.ensurePolished() - - # Create high-DPI aware pixmap - device_pixel_ratio = painter.device().devicePixelRatio() - pixmap = QPixmap(node_details.size() * device_pixel_ratio) - pixmap.setDevicePixelRatio(device_pixel_ratio) - pixmap.fill(Qt.transparent) - - # Render directly to pixmap - node_details.render(pixmap, QtCore.QPoint(), QtGui.QRegion(), - QtWidgets.QWidget.DrawChildren) - - painter.drawPixmap(option.rect.topLeft(), pixmap) - painter.restore() - return diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index d70194d4a..441e38813 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -21,5 +21,4 @@ from .drop_overlay import ABDropOverlay from .tree_view import ABTreeView from .buttons import ABCloseButton, ABMinimizeButton -from .tab_widget import ABTabWidget -from .node_details import NodeDetails +from .tab_widget import ABTabWidget \ No newline at end of file diff --git a/activity_browser/ui/widgets/node_details.py b/activity_browser/ui/widgets/node_details.py deleted file mode 100644 index f6760bf41..000000000 --- a/activity_browser/ui/widgets/node_details.py +++ /dev/null @@ -1,138 +0,0 @@ -from typing import TypedDict - -import pandas as pd - -from PySide6 import QtGui -from qtpy import QtCore, QtWidgets - -from activity_browser.ui import icons - -class NodeData(TypedDict): - database: str - name: str - product: str | None - unit: str - location: str | None - categories: list[str] | None - type: str - - -class NodeDetails(QtWidgets.QFrame): - clicked = QtCore.Signal(dict) - - def __init__(self, node_data: NodeData, parent=None, selected=False): - super().__init__(parent) - - self.node_data = node_data - self.selected = selected - - # Get the icon for this node type - node_type = node_data.get('type', '') - self.icon = self.decorationData(node_type) - - # Set frame shape for proper rendering - self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel) - - # Set cursor to pointer to indicate clickability - self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) - - # Set minimum height - self.setMinimumHeight(40) - - # Apply stylesheet with actual color values - # Use the highlight border when selected - border_color = "palette(highlight)" if selected else "palette(mid)" - self.setStyleSheet(f""" - NodeDetails {{ - border: 1px solid {border_color}; - border-radius: 3px; - margin: 2px; - background-color: palette(base); - }} - NodeDetails:hover {{ - border: 1px solid palette(highlight); - }} - """) - - line_height = self.fontMetrics().height() - - layout = QtWidgets.QVBoxLayout(self) - - name = self.node_data.get('product') or self.node_data.get('name') - name_font = self.font() - name_font.setPointSize(10) - name_font.setWeight(QtGui.QFont.Weight.DemiBold) - name_label = QtWidgets.QLabel(name) - name_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) - name_label.setFont(name_font) - name_label.setWordWrap(True) - name_label.setFixedHeight(int(line_height * 2.2)) - - producer = self.node_data.get("name") if self.node_data.get("product") else "" - producer_font = self.font() - producer_font.setPointSize(7) - producer_label = QtWidgets.QLabel(producer) - producer_label.setFont(producer_font) - producer_label.setFixedHeight(line_height) - - categories = self.node_data.get("categories", []) - categories = [] if pd.isna(categories) else categories - categories = ", ".join(categories) - categories_font = producer_font - categories_label = QtWidgets.QLabel(categories) - categories_label.setFont(categories_font) - categories_label.setFixedHeight(line_height) - - crumbs = [node_data.get('unit'), node_data.get('location'), node_data.get('database')] - crumbs = [str(crumb) for crumb in crumbs if str(crumb).strip() not in ("nan", "None", "")] - crumbs_text = " | ".join(crumbs) - crumbs_font = self.font() - crumbs_font.setPointSize(6) - crumbs_label = QtWidgets.QLabel(crumbs_text) - crumbs_label.setFont(crumbs_font) - crumbs_label.setFixedHeight(int(line_height * 0.8)) - - layout.addWidget(name_label) - layout.addWidget(producer_label) if producer else layout.addWidget(categories_label) - layout.addWidget(crumbs_label, alignment=QtCore.Qt.AlignmentFlag.AlignRight) - - self.setLayout(layout) - - def decorationData(self, node_type: str) -> QtGui.QIcon: - if node_type == "product": - return icons.qicons.product - if node_type == "waste": - return icons.qicons.waste - if node_type == "processwithreferenceproduct": - return icons.qicons.processproduct - if node_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: - return icons.qicons.biosphere - return icons.qicons.process - - def paintEvent(self, event): - """Paint the frame with icon in background""" - super().paintEvent(event) - - if self.icon and not self.icon.isNull(): - painter = QtGui.QPainter(self) - painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) - - # Set opacity for the background icon - painter.setOpacity(0.1) - - # Calculate icon size and position (right side of the frame) - icon_size = int(self.height() * 0.8) - x = self.width() - icon_size - 10 - y = (self.height() - icon_size) // 2 - - # Draw the icon - pixmap = self.icon.pixmap(icon_size, icon_size) - painter.drawPixmap(x, y, pixmap) - - painter.end() - - def mousePressEvent(self, event): - """Handle mouse click events""" - if event.button() == QtCore.Qt.MouseButton.LeftButton: - self.clicked.emit(self.node_data) - super().mousePressEvent(event) \ No newline at end of file From cb0b5612c82af106c79bee463071c9a0e0a5a753 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 27 Nov 2025 11:31:13 +0100 Subject: [PATCH 145/267] Updated node_tab info --- .../dialogs/import_preview_dialog/node_tab.py | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py index ceb886726..cf2bb6cf9 100644 --- a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py +++ b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py @@ -10,7 +10,7 @@ class ImportPreviewNodeTab(QtWidgets.QWidget): - standardNodeColumns = ["type", "name", "exchanges", "unlinked_exchanges", "location", "unit", "categories", "code", + standardNodeColumns = ["type", "name", "product", "exchanges", "unlinked_exchanges", "location", "unit", "categories", "code", "database"] standardEdgeColumns = ["type", "amount", "unit", "input", "name", "location", "database", "formula"] @@ -104,7 +104,7 @@ class ImportPreviewNodeView(widgets.ABTreeView): class ImportPreviewNodeModel(core.ABTreeModel): """Model for import preview nodes with node delegate support.""" - def displayData(self, index: QModelIndex) -> any: + def displayData(self, index: QtCore.QModelIndex) -> any: if not index.isValid(): return None @@ -113,17 +113,47 @@ def displayData(self, index: QModelIndex) -> any: return super().displayData(index) row_data = self.row(index) + row_data.dropna(inplace=True) + + # Get the product or name for title + title = row_data.get("product") or row_data.get("name") + + # Build subtitle with type and database + if row_data.get("categories"): + subtitle = ", ".join([str(cat) for cat in row_data.get("categories")]) + elif row_data.get("product"): + subtitle = row_data.get("name") + else: + excs = row_data.get("exchanges") + unlinked = row_data.get("unlinked_exchanges") + nomination = "exchanges" if excs != 1 else "exchange" + + subtitle = f"{excs} {nomination}, {unlinked} unlinked" + + # Build categories list from unit, location + categories = [] + if row_data.get("unit"): + categories.append(str(row_data.get("unit"))) + if row_data.get("location"): + categories.append(str(row_data.get("location"))) + if row_data.get("database"): + categories.append(str(row_data.get("database"))) return { - "title": row_data.get("name"), - "subtitle": f"{row_data.get('type').capitalize()} in {row_data.get('database')}", - "categories": row_data.get("categories") or [], + "title": title, + "subtitle": subtitle, + "categories": categories if categories else None, } + def decorationData(self, index: QModelIndex) -> QtGui.QIcon: if not index.isValid(): return icons.qicons.empty + column_name = self.columns()[index.column()] + if not column_name in ["node", "type"]: + return super().decorationData(index) + node_type = self.get(index, "type") if node_type == "product": From d61f523e443e8d1aa38628ed55fd225c9409008b Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 27 Nov 2025 14:05:01 +0100 Subject: [PATCH 146/267] Edge tab for import --- .../dialogs/import_preview_dialog/edge_tab.py | 180 ++++++++++++++++++ .../import_preview_dialog.py | 3 + .../dialogs/import_preview_dialog/node_tab.py | 2 +- activity_browser/bwutils/strategies.py | 4 +- activity_browser/ui/core/tree_model.py | 2 +- activity_browser/ui/delegates/card.py | 32 +++- activity_browser/ui/widgets/tree_view.py | 4 +- 7 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 activity_browser/app/dialogs/import_preview_dialog/edge_tab.py diff --git a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py new file mode 100644 index 000000000..19e02aaa7 --- /dev/null +++ b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py @@ -0,0 +1,180 @@ +from PySide6.QtCore import QModelIndex +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt + +import pandas as pd + +from bw2io.importers.base_lci import LCIImporter + +from activity_browser.ui import widgets, core, delegates, icons +from activity_browser.ui.delegates import CardDelegate + + +class ImportPreviewEdgeTab(QtWidgets.QWidget): + standardEdgeColumns = ["type", "amount", "unit", "input", "name", "location", "database", "formula"] + + def __init__(self, importer: LCIImporter, parent=None): + super().__init__(parent) + self.importer = importer + self.simple = True + + layout = QtWidgets.QVBoxLayout(self) + + self.edge_model = ImportPreviewEdgeModel(parent=self) + self.edge_model.set_dataframe(self.build_df()) + + self.edge_view = ImportPreviewEdgeView(parent=self) + self.edge_view.setUniformRowHeights(False) + self.edge_view.setModel(self.edge_model) + self.edge_view.setColumnWidth(0, 0) + self.edge_view.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection) + + # Create simple/detailed view toggle + self.view_toggle = QtWidgets.QCheckBox("Details") + self.view_toggle.setChecked(not self.simple) + self.view_toggle.setToolTip("Toggle between simple and detailed view") + self.view_toggle.checkStateChanged.connect(self.on_mode_switch) + + # Create top bar with toggle + top_bar = QtWidgets.QHBoxLayout() + top_bar.addStretch() + top_bar.addWidget(self.view_toggle) + + layout.addLayout(top_bar) + layout.addWidget(self.edge_view) + + self.sync() + + def sync(self): + """Synchronize the view based on simple/detailed mode.""" + self.edge_view.header().setHidden(self.simple) + self.edge_view.viewport().setBackgroundRole( + QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) + self.edge_view.setFrameShape( + QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) + + df = self.edge_model.df.copy() + if self.simple and "_exc" in df.columns: + df.rename(columns={"_exc": "exc"}, inplace=True) + elif not self.simple and "node" in df.columns: + df.rename(columns={"exc": "_exc"}, inplace=True) + self.edge_model.set_dataframe(df) + self.edge_model.group(["_node"]) + + for col in self.edge_model.columns(): + if col == "index": + continue + index = self.edge_model.columns().index(col) + + hidden = (self.simple and not col == "exc") or (not self.simple and col == "exc") + self.edge_view.setColumnHidden(index, hidden) + + def build_df(self): + + exchanges = [] + for node in self.importer.data: + summary = [ + node.get("name"), + node.get("location"), + node.get("database"), + node.get("code"), + ] + summary = " | ".join([str(part) for part in summary if part]) + + for ex in node.get("exchanges", []): + ex_copy = ex.copy() + ex_copy["_node"] = summary + exchanges.append(ex_copy) + + df = pd.DataFrame(exchanges) + df["exc"] = None + + return df + + def on_mode_switch(self, check: Qt.CheckState): + """Handle the mode switch between simple and detailed view.""" + self.simple = check == Qt.CheckState.Unchecked + self.sync() + + +class ShiftedCardDelegate(delegates.CardDelegate): + def paint(self, painter, option, index): + # Adjust the rect to shift content left, compensating for indentation + adjusted_option = QtWidgets.QStyleOptionViewItem(option) + adjusted_option.rect.adjust(-28, 0, 0, 0) + + # Call the original paint with adjusted rect + super().paint(painter, adjusted_option, index) + + +class ImportPreviewEdgeView(widgets.ABTreeView): + """View for displaying import preview nodes.""" + + defaultColumnDelegates = { + "exc": ShiftedCardDelegate, + } + + +class ImportPreviewEdgeModel(core.ABTreeModel): + """Model for import preview nodes with node delegate support.""" + + def displayData(self, index: QtCore.QModelIndex) -> any: + if not index.isValid(): + return None + + column_name = self.columns()[index.column()] + if not column_name == "exc": + return super().displayData(index) + + row_data = self.row(index).copy() + row_data.dropna(inplace=True) + + # Build the card information + title = row_data.get('reference product') or row_data.get('name') + subtitle = row_data.get('name') + detail = f"{row_data.get('amount')} {row_data.get('unit')}" + + # Build categories list from unit, location + categories = [] + if row_data.get("type"): + categories.append(str(row_data.get("type"))) + if row_data.get("location"): + categories.append(str(row_data.get("location"))) + if row_data.get("categories"): + categories.append(", ".join([str(cat) for cat in row_data.get("categories")])) + if row_data.get("database"): + categories.append(str(row_data.get("database"))) + + return { + "title": title, + "subtitle": subtitle, + "categories": categories if categories else None, + "detail": detail, + } + + + def decorationData(self, index: QModelIndex) -> QtGui.QIcon: + if not index.isValid(): + return icons.qicons.empty + + column_name = self.columns()[index.column()] + if not column_name in ["exc", "type"]: + return super().decorationData(index) + + node_type = self.get(index, "type") + + if node_type == "product": + return icons.qicons.product + if node_type == "waste": + return icons.qicons.waste + if node_type == "processwithreferenceproduct": + return icons.qicons.processproduct + if node_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: + return icons.qicons.biosphere + return icons.qicons.process + + + + + + diff --git a/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py b/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py index d6a5124c3..4c4eb3c0d 100644 --- a/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py +++ b/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py @@ -7,6 +7,7 @@ from activity_browser.ui import widgets, core from .node_tab import ImportPreviewNodeTab +from .edge_tab import ImportPreviewEdgeTab class ImportPreviewDialog(QtWidgets.QDialog): @@ -19,8 +20,10 @@ def __init__(self, importer: LCIImporter, parent=None): self.tabs = QtWidgets.QTabWidget(self) self.node_tab = ImportPreviewNodeTab(importer, self) + self.edge_tab = ImportPreviewEdgeTab(importer, self) self.tabs.addTab(self.node_tab, "Nodes") + self.tabs.addTab(self.edge_tab, "Edges") layout = QtWidgets.QVBoxLayout(self) layout.addWidget(self.tabs) diff --git a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py index cf2bb6cf9..efaa2808f 100644 --- a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py +++ b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py @@ -112,7 +112,7 @@ def displayData(self, index: QtCore.QModelIndex) -> any: if not column_name == "node": return super().displayData(index) - row_data = self.row(index) + row_data = self.row(index).copy() row_data.dropna(inplace=True) # Get the product or name for title diff --git a/activity_browser/bwutils/strategies.py b/activity_browser/bwutils/strategies.py index 21b7d03de..db3fc6b1b 100644 --- a/activity_browser/bwutils/strategies.py +++ b/activity_browser/bwutils/strategies.py @@ -253,10 +253,10 @@ def alter_database_name(data: list, old: str, new: str) -> list: # Note: this will only alter database if the field exists in the exchange. if exc.get("database") == old: exc["database"] = new - for p, d in ds.get("parameters", {}).items(): + for p in ds.get("parameters", []): # Any parameters found here are activity parameters and we can # overwrite the database without issue. - d["database"] = new + p["database"] = new if ds.get("processor", (None, None))[0] == old: ds["processor"] = (new, ds["processor"][1]) return data diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index ee21850b7..7fb45065f 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -281,7 +281,7 @@ def headerData(self, section: int, orientation: Qt.Orientation = Qt.Horizontal, if section == 0: return "" - return self.df.columns[section - 1] + return self.columns()[section] if role == Qt.ItemDataRole.FontRole and section in self.filtered_columns: font = QtGui.QFont() diff --git a/activity_browser/ui/delegates/card.py b/activity_browser/ui/delegates/card.py index a6c3d4a71..901e01dd3 100644 --- a/activity_browser/ui/delegates/card.py +++ b/activity_browser/ui/delegates/card.py @@ -7,8 +7,8 @@ class CardData(TypedDict): title: str subtitle: str | None + detail: str | None categories: list[str] | None - icon: QtGui.QIcon | None class CardDelegate(QtWidgets.QStyledItemDelegate): @@ -147,6 +147,27 @@ def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): painter.drawText(subtitle_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, subtitle_text) y += subtitle_height + # Draw detail (bottom left) + detail = card_data.get('detail', '') + detail_width = 0 + if detail: + detail_font = option.font + detail_font.setPointSize(int(option.font.pointSize() * 0.8)) + painter.setFont(detail_font) + + detail_fm = QtGui.QFontMetrics(detail_font) + detail_height = detail_fm.height() + + # Reserve half width for detail, half for categories + max_detail_width = text_rect.width() // 2 - 10 + detail_rect = QtCore.QRect(text_rect.left(), text_rect.bottom() - detail_height, + max_detail_width, detail_height) + + # Elide detail if too long + detail_text_elided = detail_fm.elidedText(str(detail), Qt.TextElideMode.ElideRight, max_detail_width) + painter.drawText(detail_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, detail_text_elided) + detail_width = detail_fm.horizontalAdvance(detail_text_elided) + 10 + # Draw categories (pipe separated, bottom right) categories = card_data.get('categories', []) if categories and isinstance(categories, (list, tuple)): @@ -157,11 +178,14 @@ def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): categories_fm = QtGui.QFontMetrics(categories_font) categories_height = categories_fm.height() - categories_rect = QtCore.QRect(text_rect.left(), text_rect.bottom() - categories_height, - text_rect.width(), categories_height) + + # Adjust width to account for detail on left + available_width = text_rect.width() - detail_width + categories_rect = QtCore.QRect(text_rect.left() + detail_width, text_rect.bottom() - categories_height, + available_width, categories_height) # Elide categories if too long - categories_text_elided = categories_fm.elidedText(categories_text, Qt.TextElideMode.ElideRight, categories_rect.width()) + categories_text_elided = categories_fm.elidedText(categories_text, Qt.TextElideMode.ElideRight, available_width) painter.drawText(categories_rect, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, categories_text_elided) painter.restore() diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py index 28555c67d..92df3015d 100644 --- a/activity_browser/ui/widgets/tree_view.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -83,6 +83,8 @@ def __init__(self, parent=None): self.header().setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) self.header().customContextMenuRequested.connect(self.showHeaderMenu) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) + self.columnFilters: dict[str, str] = {} # dict[column_name, query] for filtering the dataframe self.allFilter: str = "" # filter applied to the entire dataframe @@ -95,7 +97,7 @@ def setModel(self, model): model.modelAboutToBeReset.connect(self.clearColumnDelegates) model.modelReset.connect(self.setDefaultColumnDelegates) model.layoutChanged.connect(self.updateIndexColumnVisibility) - model.layoutChanged.connect(self.updateBranchSpanning) + model.layoutChanged.connect(self.updateBranchSpanning, QtCore.Qt.ConnectionType.QueuedConnection) self.setDefaultColumnDelegates() self.updateIndexColumnVisibility() From 3b58700942aef4a8f3c98cf4848d5c0348369947 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 27 Nov 2025 14:28:06 +0100 Subject: [PATCH 147/267] Add exchange link icons --- .../dialogs/import_preview_dialog/edge_tab.py | 18 ++++++------------ activity_browser/ui/icons.py | 7 +++++++ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py index 19e02aaa7..70aa0b10b 100644 --- a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py +++ b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py @@ -158,20 +158,14 @@ def decorationData(self, index: QModelIndex) -> QtGui.QIcon: return icons.qicons.empty column_name = self.columns()[index.column()] - if not column_name in ["exc", "type"]: + if not column_name in ["exc"]: return super().decorationData(index) - node_type = self.get(index, "type") - - if node_type == "product": - return icons.qicons.product - if node_type == "waste": - return icons.qicons.waste - if node_type == "processwithreferenceproduct": - return icons.qicons.processproduct - if node_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: - return icons.qicons.biosphere - return icons.qicons.process + linked = self.row(index).get("input") is not None + if linked: + return icons.qicons.link + else: + return icons.qicons.unlink diff --git a/activity_browser/ui/icons.py b/activity_browser/ui/icons.py index d1c54d8b7..b321d3c8c 100644 --- a/activity_browser/ui/icons.py +++ b/activity_browser/ui/icons.py @@ -4,6 +4,8 @@ from qtpy.QtCore import Qt, QSize from qtpy.QtGui import QIcon, QPixmap +from activity_browser.bwutils.strategies import link_exchanges_without_db + PACKAGE_DIR = Path(__file__).resolve().parents[1] @@ -70,6 +72,11 @@ def empty_icon(size: QSize = QSize(32, 32)) -> QIcon: biosphere = create_path("nodes", "biosphere.png"), readonly_process = create_path("nodes", "read-only-process.png"), + # exchanges + link = create_path("exchanges", "link.png"), + unlink = create_path("exchanges", "unlink.png"), + relink = create_path("exchanges", "relink.png"), + # other superstructure = create_path("main", "superstructure.png"), copy_to_clipboard = create_path("main", "copy_to_clipboard.png"), From d4a18c4b3de3b95c4fd8b71df5dea10afa02abeb Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 27 Nov 2025 14:33:05 +0100 Subject: [PATCH 148/267] Card font sizing fix --- activity_browser/ui/delegates/card.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/activity_browser/ui/delegates/card.py b/activity_browser/ui/delegates/card.py index 901e01dd3..591d70fff 100644 --- a/activity_browser/ui/delegates/card.py +++ b/activity_browser/ui/delegates/card.py @@ -54,6 +54,7 @@ def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): card_data = index.data() is_selected = option.state & QtWidgets.QStyle.StateFlag.State_Selected + font_size = option.font.pointSize() # Draw background and border rect = option.rect.adjusted(self.MARGIN, self.MARGIN, -self.MARGIN, -self.MARGIN) @@ -134,7 +135,7 @@ def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): subtitle = card_data.get('subtitle', '') if subtitle: subtitle_font: QtGui.QFont = option.font - subtitle_font.setPointSize(int(option.font.pointSize() * 0.9)) + subtitle_font.setPointSize(int(font_size * 0.9)) subtitle_font.setWeight(QtGui.QFont.Weight.Light) painter.setFont(subtitle_font) @@ -152,7 +153,7 @@ def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): detail_width = 0 if detail: detail_font = option.font - detail_font.setPointSize(int(option.font.pointSize() * 0.8)) + detail_font.setPointSize(int(font_size * 0.8)) painter.setFont(detail_font) detail_fm = QtGui.QFontMetrics(detail_font) @@ -173,7 +174,7 @@ def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): if categories and isinstance(categories, (list, tuple)): categories_text = " | ".join(str(cat) for cat in categories) categories_font = option.font - categories_font.setPointSize(int(option.font.pointSize() * 0.8)) + categories_font.setPointSize(int(font_size * 0.8)) painter.setFont(categories_font) categories_fm = QtGui.QFontMetrics(categories_font) From 8abc88d32db92ee25b09015db336bdff56584fad Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 1 Dec 2025 09:44:15 +0100 Subject: [PATCH 149/267] Stuff --- .../app/actions/node_select_open.py | 15 +++- .../dialogs/import_preview_dialog/edge_tab.py | 80 ++++++++++++++++--- .../app/dialogs/node_select_dialog.py | 56 ++++++------- activity_browser/ui/widgets/menu.py | 8 ++ 4 files changed, 114 insertions(+), 45 deletions(-) diff --git a/activity_browser/app/actions/node_select_open.py b/activity_browser/app/actions/node_select_open.py index 070c848c8..1dcae5465 100644 --- a/activity_browser/app/actions/node_select_open.py +++ b/activity_browser/app/actions/node_select_open.py @@ -5,8 +5,7 @@ from activity_browser.ui.icons import qicons from activity_browser.ui.core.application import global_shortcut - - +from .activity.activity_open import ActivityOpen class NodeSelectOpen(ABAction): @@ -18,5 +17,13 @@ class NodeSelectOpen(ABAction): @exception_dialogs def run(): from activity_browser.app import dialogs - dialog = dialogs.NodeSelectDialog(parent=app.main_window) - dialog.exec_() \ No newline at end of file + dialog = dialogs.NodeSelectDialog(parent=app.main_window, drag_enabled=True) + + dialog.exec_() + if dialog.result() != dialog.DialogCode.Accepted: + return + + selected_node = dialog.get_selected_node() + if selected_node: + logger.debug(f"Opening node: {selected_node}") + ActivityOpen.run([selected_node]) \ No newline at end of file diff --git a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py index 70aa0b10b..0b0fe7aac 100644 --- a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py +++ b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py @@ -7,7 +7,8 @@ from bw2io.importers.base_lci import LCIImporter from activity_browser.ui import widgets, core, delegates, icons -from activity_browser.ui.delegates import CardDelegate + +from ..node_select_dialog import NodeSelectDialog class ImportPreviewEdgeTab(QtWidgets.QWidget): @@ -17,17 +18,17 @@ def __init__(self, importer: LCIImporter, parent=None): super().__init__(parent) self.importer = importer self.simple = True + self.old_links = {} layout = QtWidgets.QVBoxLayout(self) self.edge_model = ImportPreviewEdgeModel(parent=self) self.edge_model.set_dataframe(self.build_df()) - self.edge_view = ImportPreviewEdgeView(parent=self) + self.edge_view = ImportPreviewEdgeView(importer, self) self.edge_view.setUniformRowHeights(False) self.edge_view.setModel(self.edge_model) self.edge_view.setColumnWidth(0, 0) - self.edge_view.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection) # Create simple/detailed view toggle self.view_toggle = QtWidgets.QCheckBox("Details") @@ -53,7 +54,7 @@ def sync(self): self.edge_view.setFrameShape( QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) - df = self.edge_model.df.copy() + df = self.build_df() if self.simple and "_exc" in df.columns: df.rename(columns={"_exc": "exc"}, inplace=True) elif not self.simple and "node" in df.columns: @@ -72,7 +73,7 @@ def sync(self): def build_df(self): exchanges = [] - for node in self.importer.data: + for node_i, node in enumerate(self.importer.data): summary = [ node.get("name"), node.get("location"), @@ -81,13 +82,17 @@ def build_df(self): ] summary = " | ".join([str(part) for part in summary if part]) - for ex in node.get("exchanges", []): - ex_copy = ex.copy() - ex_copy["_node"] = summary - exchanges.append(ex_copy) + for exc_i, exc in enumerate(node.get("exchanges", [])): + exc = exc.copy() + exc["_node"] = summary + exc["_location"] = (node_i, exc_i) + exchanges.append(exc) df = pd.DataFrame(exchanges) + for col in [col for col in self.standardEdgeColumns if col not in df.columns]: + df[col] = None df["exc"] = None + df["linked"] = df["input"].apply(lambda x: "linked" if isinstance(x, tuple) else "unlinked") return df @@ -96,6 +101,28 @@ def on_mode_switch(self, check: Qt.CheckState): self.simple = check == Qt.CheckState.Unchecked self.sync() + def relink_selected_exchanges(self): + """Open a dialog to link selected exchanges to existing nodes.""" + exchange_locations = self.edge_view.selected_exchanges + if not exchange_locations: + return + + dialog = NodeSelectDialog(parent=self) + if not dialog.exec() == QtWidgets.QDialog.DialogCode.Accepted: + return + + selected_node = dialog.get_selected_node() + + for loc in exchange_locations: + node_i, exc_i = loc + + if loc not in self.old_links: + self.old_links[loc] = self.importer.data[node_i]["exchanges"][exc_i].get("input") + + self.importer.data[node_i]["exchanges"][exc_i]["input"] = (selected_node["database"], selected_node["code"]) + + self.sync() + class ShiftedCardDelegate(delegates.CardDelegate): def paint(self, painter, option, index): @@ -114,6 +141,24 @@ class ImportPreviewEdgeView(widgets.ABTreeView): "exc": ShiftedCardDelegate, } + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m, p: m.callback( + text="Link exchange" if len(p.selected_exchanges) == 1 else "Link exchanges", + func=p.tab.relink_selected_exchanges, + ) + ] + + def __init__(self, importer: LCIImporter, tab: ImportPreviewEdgeTab): + super().__init__(tab) + self.importer = importer + self.old_links = {} + self.tab = tab + + @property + def selected_exchanges(self): + return list(set([self.model().get(index, "_location") for index in self.selectedIndexes()])) + class ImportPreviewEdgeModel(core.ABTreeModel): """Model for import preview nodes with node delegate support.""" @@ -123,7 +168,7 @@ def displayData(self, index: QtCore.QModelIndex) -> any: return None column_name = self.columns()[index.column()] - if not column_name == "exc": + if not column_name == "exc" or self.row(index) is None: return super().displayData(index) row_data = self.row(index).copy() @@ -161,11 +206,20 @@ def decorationData(self, index: QModelIndex) -> QtGui.QIcon: if not column_name in ["exc"]: return super().decorationData(index) - linked = self.row(index).get("input") is not None - if linked: + linked = self.get(index, "linked") + if linked == "linked": return icons.qicons.link - else: + elif linked == "unlinked": return icons.qicons.unlink + elif linked == "relinked": + return icons.qicons.relink + return icons.qicons.empty + + def indexSelectable(self, index: QModelIndex) -> bool: + # Don't make the tree column selectable + if index.column() == 0: + return False + return True diff --git a/activity_browser/app/dialogs/node_select_dialog.py b/activity_browser/app/dialogs/node_select_dialog.py index 2f2ac0a5f..fc00dd423 100644 --- a/activity_browser/app/dialogs/node_select_dialog.py +++ b/activity_browser/app/dialogs/node_select_dialog.py @@ -4,12 +4,13 @@ from activity_browser.ui import widgets, core, delegates, icons from activity_browser.app import metadata, actions +from activity_browser.bwutils.commontasks import refresh_node class NodeSelectDialog(QtWidgets.QDialog): node_selected = QtCore.Signal(dict) - def __init__(self, parent=None): + def __init__(self, parent=None, drag_enabled=False): super().__init__(parent) self.setWindowFlags( @@ -29,8 +30,9 @@ def __init__(self, parent=None): self.tree_view = NodeSearchView(self) self.tree_view.setModel(self.model) - self.tree_view.doubleClicked.connect(self.on_node_double_clicked) + self.tree_view.clicked.connect(self.accept) self.tree_view.dragStarted.connect(self.on_drag_started) + self.tree_view.setDragEnabled(drag_enabled) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(5, 0, 5, 0) @@ -40,18 +42,18 @@ def __init__(self, parent=None): self.setFixedHeight(self.sizeHint().height()) - def showEvent(self, event): - """Position the dialog 200px higher than default centered position""" - super().showEvent(event) - if self.parent(): - parent_rect = self.parent().geometry() - dialog_rect = self.geometry() - - # Center horizontally, but move up 200px from center vertically - x = parent_rect.x() + (parent_rect.width() - dialog_rect.width()) // 2 - y = parent_rect.y() + (parent_rect.height() - dialog_rect.height()) // 2 - 200 - - self.move(x, y) + # def showEvent(self, event): + # """Position the dialog 200px higher than default centered position""" + # super().showEvent(event) + # if self.parent(): + # parent_rect = self.parent().geometry() + # dialog_rect = self.geometry() + # + # # Center horizontally, but move up 200px from center vertically + # x = parent_rect.x() + (parent_rect.width() - dialog_rect.width()) // 2 + # y = parent_rect.y() + (parent_rect.height() - dialog_rect.height()) // 2 - 200 + # + # self.move(x, y) def on_search(self, text: str): if not text.strip(): @@ -82,22 +84,21 @@ def on_search(self, text: str): self.adjustSize() self.setFixedHeight(self.sizeHint().height()) - def on_node_double_clicked(self, index: QtCore.QModelIndex): - """Handle when a node is double-clicked in the tree view""" - if not index.isValid(): - return - - # Get node data from the model - node_id = self.model.get(index, "id") - if node_id: - self.node_selected.emit(node_id) - actions.ActivityOpen.run([node_id]) - self.accept() # Close the dialog - def on_drag_started(self): """Handle when a drag operation is started""" self.hide() # Close the dialog + def get_selected_node(self): + """Return the currently selected node data""" + index = self.tree_view.currentIndex() + if not index.isValid(): + return None + node_id = self.model.get(index, "id") + if not node_id: + return None + return refresh_node(node_id) + + class NodeSearchModel(core.ABTreeModel): """Model for displaying search results in the node select dialog.""" @@ -115,7 +116,7 @@ def displayData(self, index: QtCore.QModelIndex) -> any: if not column_name == "node": return super().displayData(index) - row_data = self.row(index) + row_data = self.row(index).copy() row_data.dropna(inplace=True) # Get the product or name for title @@ -194,7 +195,6 @@ def __init__(self, parent: NodeSelectDialog): self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) self.setHeaderHidden(True) - self.setDragEnabled(True) self.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) self.setFixedHeight(0) diff --git a/activity_browser/ui/widgets/menu.py b/activity_browser/ui/widgets/menu.py index 6d7e667fd..367745b8b 100644 --- a/activity_browser/ui/widgets/menu.py +++ b/activity_browser/ui/widgets/menu.py @@ -19,3 +19,11 @@ def __init__(self, pos=None, parent=None, title: str = None): def add(self, action, *args, enable=True, text=None, **kwargs): qaction = action.get_QAction(*args, parent=self, enabled=enable, text=text, **kwargs) self.addAction(qaction) + + def callback(self, text: str, func: Callable, args: list = None, kwargs: dict = None): + args = args or [] + kwargs = kwargs or {} + + action = QtWidgets.QAction(text, self) + action.triggered.connect(lambda: func(*args, **kwargs)) + self.addAction(action) From fc9cb77f6e165d7514cf3d579b65d1c097583dc2 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 1 Dec 2025 11:07:14 +0100 Subject: [PATCH 150/267] Keeping tree state when linking --- .../dialogs/import_preview_dialog/edge_tab.py | 31 ++- activity_browser/ui/core/tree_model.py | 262 +++++++++--------- activity_browser/ui/delegates/card.py | 2 +- 3 files changed, 155 insertions(+), 140 deletions(-) diff --git a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py index 0b0fe7aac..5891e7a6f 100644 --- a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py +++ b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py @@ -12,18 +12,19 @@ class ImportPreviewEdgeTab(QtWidgets.QWidget): - standardEdgeColumns = ["type", "amount", "unit", "input", "name", "location", "database", "formula"] + standardEdgeColumns = ["linked", "type", "amount", "unit", "input", "name", "location", "database", "formula"] def __init__(self, importer: LCIImporter, parent=None): super().__init__(parent) self.importer = importer self.simple = True - self.old_links = {} + self.old_links: dict[tuple[int, int], tuple[str, str] | None] = {} layout = QtWidgets.QVBoxLayout(self) self.edge_model = ImportPreviewEdgeModel(parent=self) self.edge_model.set_dataframe(self.build_df()) + self.edge_model.group(["_node"]) self.edge_view = ImportPreviewEdgeView(importer, self) self.edge_view.setUniformRowHeights(False) @@ -55,12 +56,13 @@ def sync(self): QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) df = self.build_df() + if self.simple and "_exc" in df.columns: df.rename(columns={"_exc": "exc"}, inplace=True) elif not self.simple and "node" in df.columns: df.rename(columns={"exc": "_exc"}, inplace=True) - self.edge_model.set_dataframe(df) - self.edge_model.group(["_node"]) + + self.edge_model.update_dataframe(df) for col in self.edge_model.columns(): if col == "index": @@ -92,7 +94,19 @@ def build_df(self): for col in [col for col in self.standardEdgeColumns if col not in df.columns]: df[col] = None df["exc"] = None - df["linked"] = df["input"].apply(lambda x: "linked" if isinstance(x, tuple) else "unlinked") + + def determine_link_status(row): + input_val = row["input"] + location = row["_location"] + + if not isinstance(input_val, tuple): + return "unlinked" + elif location in self.old_links: + return "relinked" + else: + return "linked" + + df["linked"] = df.apply(determine_link_status, axis=1) return df @@ -125,6 +139,9 @@ def relink_selected_exchanges(self): class ShiftedCardDelegate(delegates.CardDelegate): + """ + Delegate that shifts the card content to the left to compensate for indentation. + """ def paint(self, painter, option, index): # Adjust the rect to shift content left, compensating for indentation adjusted_option = QtWidgets.QStyleOptionViewItem(option) @@ -157,6 +174,10 @@ def __init__(self, importer: LCIImporter, tab: ImportPreviewEdgeTab): @property def selected_exchanges(self): + """ + Returns a list of selected exchange locations as (node_index, exchange_index) tuples. These can be used to + identify and manipulate the selected exchanges in the importer's data, which is a list of lists. + """ return list(set([self.model().get(index, "_location") for index in self.selectedIndexes()])) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 7fb45065f..6cb5029c5 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -59,6 +59,7 @@ def __init__(self, df: pd.DataFrame = None, parent: Optional[QWidget] = None, ch self.df_query: dict[str, str] = {"model": "index == index"} # dictionary where queries can be registered self.filtered_columns: set[int] = set() # set of column indices that have active filters, only used for the header icon + self.grouped_columns: list[str] = [] # list of columns currently used for grouping self.lazy = chunk_size > 0 self.chunk_size = chunk_size @@ -332,6 +333,121 @@ def fetchMore(self, parent: QModelIndex) -> None: self.endInsertRows() # --- helper functions --- + def set_dataframe(self, df: pd.DataFrame) -> None: + self.beginResetModel() + self.df = df + self.build_df_index() + self.reset_hierarchy() + self.endResetModel() + + def update_dataframe(self, df: pd.DataFrame) -> None: + self.layoutAboutToBeChanged.emit() + self.df = df + self.build_df_index() + self.reset_hierarchy() + self.layoutChanged.emit() + + def group(self, columns: list[str] = None) -> None: + self.layoutAboutToBeChanged.emit() + self.grouped_columns = columns or self.grouped_columns + self.build_df_index() + self.reset_hierarchy() + self.layoutChanged.emit() + + def ungroup(self) -> None: + self.layoutAboutToBeChanged.emit() + self.grouped_columns = [] + self.build_df_index() + self.reset_hierarchy() + self.layoutChanged.emit() + + def sort(self, column: int | str, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: + if self.df.empty: + return + + # Extract the unique order of higher levels + column_name = self.headerData(column) if isinstance(column, int) else column + higher_levels = self.df.index.droplevel(-1).unique() if self.df.index.nlevels > 1 else [None] + + # Build a new index by sorting only within each higher level + sorted_index = [] + + for lvl in higher_levels: + mask = self.df.index.droplevel(-1) == lvl if lvl is not None else self.df.index + partial_df = self.df.loc[mask, column_name or self.df.columns[0]].copy() + if column_name is not None: + partial_df.sort_values(ascending=(order == Qt.SortOrder.AscendingOrder), inplace=True) + else: + partial_df = partial_df.sort_index(ascending=(order == Qt.SortOrder.AscendingOrder)) + sorted_index.append(partial_df.index) + + sorted_index = sorted_index[0].append(sorted_index[1:]) # Flatten + self.df = self.df.loc[sorted_index] # Update dataframe to new sorted order + self.filter() + + def filter(self, key: str = None, query: str = None) -> None: + """Filter the DataFrame based on a simple substring match across all columns.""" + self.layoutAboutToBeChanged.emit() + if query is not None and key is not None: + self.df_query[key] = query + + pandas_query = " & ".join(self.df_query.values()) + filtered_df = self.df.query(pandas_query) + + self.reset_hierarchy(filtered_df) + self.layoutChanged.emit() + + def build_df_index(self): + # dataframe we will use to build the new index + df = self.df[self.grouped_columns].copy() + + # unpack iterables in the grouped columns + for col in self.grouped_columns: + # Check if the column contains iterables (excluding strings) + sample_val = df[col].dropna().iloc[0] if not df[col].dropna().empty else None + if not isinstance(sample_val, (list, tuple, set)): + continue + + # Unpack the iterable into separate columns and add to the dataframe + unpacked = pd.DataFrame(df[col].tolist(), index=df.index) + for i, unpacked_col in enumerate(unpacked.columns): + df[f"{col}_{i}"] = unpacked[unpacked_col] + + # Remove the original column from the dataframe + df = df.drop(columns=[col]) + + df["index"] = range(len(df)) + + new_index = pd.MultiIndex.from_frame(df) + new_index.names = [i + "_i" for i in new_index.names] + + self.df.index = new_index + + def reset_hierarchy(self, df: pd.DataFrame = None) -> None: + df = df if df is not None else self.df + old_persistent_indices = [(idx, idx.internalPointer()) for idx in self.persistentIndexList()] + + # Rebuild the node hierarchy + self.root = TreeNode(tuple()) + self.build_node_hierarchy(df.index) + + # Update persistent indexes + new_persistent = [] + for old_index, old_node in old_persistent_indices: + if isinstance(old_node, TreeNode): + # Try to find the same path in the new hierarchy + new_node = self.node_map.get(old_node.path) + if new_node is not None: + new_index = self.createIndex(new_node.row_in_parent, old_index.column(), new_node) + new_persistent.append(new_index) + else: + new_persistent.append(QModelIndex()) + else: + new_persistent.append(QModelIndex()) + + # Update the model's persistent indexes + self.changePersistentIndexList(self.persistentIndexList(), new_persistent) + def build_node_hierarchy(self, pandas_index: pd.Index) -> None: """ Build the unified TreeNode hierarchy with all information combined: @@ -341,54 +457,54 @@ def build_node_hierarchy(self, pandas_index: pd.Index) -> None: - DataFrame positions """ self.node_map = {tuple(): self.root} - + # Convert index to frame once for all operations idx_df = pandas_index.to_frame(index=False) - + # Create a mapping from full path to DataFrame position path_to_position = {} for row_tuple in idx_df.itertuples(index=False, name=None): df_pos = self.df.index.get_loc(row_tuple) path_to_position[row_tuple] = df_pos - + # Process each level to build the hierarchy for level in range(idx_df.shape[1]): # Get unique child paths at this level (as tuples) child_paths = idx_df.iloc[:, :level + 1].drop_duplicates() child_tuples = list(child_paths.itertuples(index=False, name=None)) - + for child_path in child_tuples: if pd.isna(child_path[-1]): continue # skip NaN children - + # Skip if we've already created this node if child_path in self.node_map: continue - + # Determine parent path if level == 0: parent_path = tuple() else: parent_path = tuple(val for val in child_path[:-1] if not pd.isna(val)) - + # Get or create parent node parent_node = self.node_map.get(parent_path) if parent_node is None: parent_node = self.root - + # Check if this is a leaf node (full depth) is_leaf = (level == idx_df.shape[1] - 1) df_position = path_to_position.get(child_path, -1) if is_leaf else -1 - + # Create the child node child_node = TreeNode(child_path, df_position) - + # Add child to parent parent_node.add_child(child_node) - + # Store in node map self.node_map[child_path] = child_node - + # Initialize loaded counts if self.lazy: # Load first chunk for each node @@ -399,128 +515,6 @@ def build_node_hierarchy(self, pandas_index: pd.Index) -> None: for node in self.node_map.values(): node.loaded_count = node.total_children() - def reset_hierarchy(self, df: pd.DataFrame = None) -> None: - df = df if df is not None else self.df - old_persistent_indices = [(idx, idx.internalPointer()) for idx in self.persistentIndexList()] - - # Rebuild the node hierarchy - self.root = TreeNode(tuple()) - self.build_node_hierarchy(df.index) - - # Update persistent indexes - new_persistent = [] - for old_index, old_node in old_persistent_indices: - if isinstance(old_node, TreeNode): - # Try to find the same path in the new hierarchy - new_node = self.node_map.get(old_node.path) - if new_node is not None: - new_index = self.createIndex(new_node.row_in_parent, old_index.column(), new_node) - new_persistent.append(new_index) - else: - new_persistent.append(QModelIndex()) - else: - new_persistent.append(QModelIndex()) - - # Update the model's persistent indexes - self.changePersistentIndexList(self.persistentIndexList(), new_persistent) - - self.layoutChanged.emit() - - - def sort(self, column: int | str, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: - if self.df.empty: - return - # Extract the unique order of higher levels - column_name = self.headerData(column) if isinstance(column, int) else column - higher_levels = self.df.index.droplevel(-1).unique() if self.df.index.nlevels > 1 else [None] - - # Build a new index by sorting only within each higher level - sorted_index = [] - - for lvl in higher_levels: - mask = self.df.index.droplevel(-1) == lvl if lvl is not None else self.df.index - partial_df = self.df.loc[mask, column_name or self.df.columns[0]].copy() - if column_name is not None: - partial_df.sort_values(ascending=(order == Qt.SortOrder.AscendingOrder), inplace=True) - else: - partial_df = partial_df.sort_index(ascending=(order == Qt.SortOrder.AscendingOrder)) - sorted_index.append(partial_df.index) - - sorted_index = sorted_index[0].append(sorted_index[1:]) # Flatten - self.df = self.df.loc[sorted_index] # Update dataframe to new sorted order - self.filter() - - def filter(self, key: str = None, query: str = None) -> None: - """Filter the DataFrame based on a simple substring match across all columns.""" - self.layoutAboutToBeChanged.emit() - if query is not None and key is not None: - self.df_query[key] = query - - pandas_query = " & ".join(self.df_query.values()) - filtered_df = self.df.query(pandas_query) - - self.reset_hierarchy(filtered_df) - self.layoutChanged.emit() - - def set_dataframe(self, df: pd.DataFrame) -> None: - self.beginResetModel() - self.df = df - self.df.index = pd.MultiIndex.from_arrays([range(len(self.df))], names=[f"index"]) - - self.reset_hierarchy() - self.endResetModel() - - def group(self, columns: list[str]) -> None: - """Regroup the DataFrame by the specified columns. - - Unpacks columns containing iterables (lists, tuples, sets) by spreading them - into separate columns that become separate levels in the multiindex. - """ - self.layoutAboutToBeChanged.emit() - df = self.df[columns].copy() - - # Build the list of columns for the new index, unpacking iterables - for col in columns: - # Check if the column contains iterables (excluding strings) - sample_val = df[col].dropna().iloc[0] if not df[col].dropna().empty else None - - if not isinstance(sample_val, (list, tuple, set)): - continue - - # Unpack the iterable into separate columns - unpacked = pd.DataFrame(df[col].tolist(), index=df.index) - - # Name the new columns - unpacked.columns = [f"{col}_{i}" for i in range(len(unpacked.columns))] - - # Add unpacked columns to the dataframe - for unpacked_col in unpacked.columns: - df[unpacked_col] = unpacked[unpacked_col] - - # Remove the original column from the dataframe - df = df.drop(columns=[col]) - - levels = list(df.columns) + list(df.index.names) - - df = df.reset_index() - df = df[levels] - - new_index = pd.MultiIndex.from_frame(df) - new_index.names = [i+"_i" if not i.endswith("_i") else i for i in new_index.names] - - self.df = self.df.set_index(new_index) - - self.reset_hierarchy() - self.layoutChanged.emit() - - def ungroup(self) -> None: - """Ungroup the DataFrame by resetting the index.""" - self.layoutAboutToBeChanged.emit() - self.df.index = pd.MultiIndex.from_arrays([range(len(self.df))], names=[f"index"]) - self.df.index.name = "index" - self.reset_hierarchy() - self.layoutChanged.emit() - def values_from_indices(self, key: str, indices: list[QModelIndex]): """ Returns the values from the given indices. diff --git a/activity_browser/ui/delegates/card.py b/activity_browser/ui/delegates/card.py index 591d70fff..542fe8857 100644 --- a/activity_browser/ui/delegates/card.py +++ b/activity_browser/ui/delegates/card.py @@ -17,7 +17,7 @@ class CardDelegate(QtWidgets.QStyledItemDelegate): PADDING = 8 MARGIN = 2 TITLE_LINES = 2 - ICON_OPACITY = 0.1 + ICON_OPACITY = 0.3 def sizeHint(self, option, index): if index.data() is None: From b0cc5ff9c0d3f11529de6197e76639766f4c8d1d Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 1 Dec 2025 11:23:17 +0100 Subject: [PATCH 151/267] Fix column filtering in grouped model --- activity_browser/ui/widgets/tree_view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py index 92df3015d..57fde65ae 100644 --- a/activity_browser/ui/widgets/tree_view.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -136,7 +136,7 @@ def setAllFilter(self, query: str): self.applyFilter() def buildQuery(self) -> str: - queries = ["(index == index)"] + queries = [] # query for the column filters for col in list(self.columnFilters): @@ -155,7 +155,7 @@ def buildQuery(self) -> str: formatted_filter = self.format_query(self.allFilter) for i, col in enumerate(self.model().columns()): - if self.isColumnHidden(i): + if col == "index" or self.isColumnHidden(i): continue all_queries.append(f"(`{col}`.astype('str').str.contains('{formatted_filter}', False))") From b1c8a3b6cb2dec4575f9867cd1d021e97bed3686 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 1 Dec 2025 11:55:14 +0100 Subject: [PATCH 152/267] Fix sorting and filtering on grouped columns --- activity_browser/ui/core/tree_model.py | 83 +++++++++++++++++--------- 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 6cb5029c5..70331c485 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -60,6 +60,8 @@ def __init__(self, df: pd.DataFrame = None, parent: Optional[QWidget] = None, ch self.df_query: dict[str, str] = {"model": "index == index"} # dictionary where queries can be registered self.filtered_columns: set[int] = set() # set of column indices that have active filters, only used for the header icon self.grouped_columns: list[str] = [] # list of columns currently used for grouping + self.sorted_column: str | None = None + self.sort_order = Qt.SortOrder.AscendingOrder self.lazy = chunk_size > 0 self.chunk_size = chunk_size @@ -336,65 +338,63 @@ def fetchMore(self, parent: QModelIndex) -> None: def set_dataframe(self, df: pd.DataFrame) -> None: self.beginResetModel() self.df = df + self.build_df_index() - self.reset_hierarchy() + self.apply_sort() + self.apply_filter() + self.endResetModel() def update_dataframe(self, df: pd.DataFrame) -> None: self.layoutAboutToBeChanged.emit() self.df = df + self.build_df_index() - self.reset_hierarchy() + self.apply_sort() + self.apply_filter() + self.layoutChanged.emit() def group(self, columns: list[str] = None) -> None: self.layoutAboutToBeChanged.emit() self.grouped_columns = columns or self.grouped_columns + self.build_df_index() - self.reset_hierarchy() + self.apply_sort() + self.apply_filter() + self.layoutChanged.emit() def ungroup(self) -> None: self.layoutAboutToBeChanged.emit() self.grouped_columns = [] + self.build_df_index() - self.reset_hierarchy() + self.apply_sort() + self.apply_filter() + self.layoutChanged.emit() def sort(self, column: int | str, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: - if self.df.empty: - return - - # Extract the unique order of higher levels - column_name = self.headerData(column) if isinstance(column, int) else column - higher_levels = self.df.index.droplevel(-1).unique() if self.df.index.nlevels > 1 else [None] + self.layoutAboutToBeChanged.emit() - # Build a new index by sorting only within each higher level - sorted_index = [] + self.sorted_column = self.headerData(column) if isinstance(column, int) else column + self.sort_order = order - for lvl in higher_levels: - mask = self.df.index.droplevel(-1) == lvl if lvl is not None else self.df.index - partial_df = self.df.loc[mask, column_name or self.df.columns[0]].copy() - if column_name is not None: - partial_df.sort_values(ascending=(order == Qt.SortOrder.AscendingOrder), inplace=True) - else: - partial_df = partial_df.sort_index(ascending=(order == Qt.SortOrder.AscendingOrder)) - sorted_index.append(partial_df.index) + self.apply_sort() + self.apply_filter() - sorted_index = sorted_index[0].append(sorted_index[1:]) # Flatten - self.df = self.df.loc[sorted_index] # Update dataframe to new sorted order - self.filter() + self.layoutChanged.emit() def filter(self, key: str = None, query: str = None) -> None: """Filter the DataFrame based on a simple substring match across all columns.""" self.layoutAboutToBeChanged.emit() + if query is not None and key is not None: self.df_query[key] = query - pandas_query = " & ".join(self.df_query.values()) - filtered_df = self.df.query(pandas_query) + self.apply_filter() - self.reset_hierarchy(filtered_df) self.layoutChanged.emit() def build_df_index(self): @@ -428,7 +428,6 @@ def reset_hierarchy(self, df: pd.DataFrame = None) -> None: old_persistent_indices = [(idx, idx.internalPointer()) for idx in self.persistentIndexList()] # Rebuild the node hierarchy - self.root = TreeNode(tuple()) self.build_node_hierarchy(df.index) # Update persistent indexes @@ -456,6 +455,7 @@ def build_node_hierarchy(self, pandas_index: pd.Index) -> None: - loaded counts - DataFrame positions """ + self.root = TreeNode(tuple()) self.node_map = {tuple(): self.root} # Convert index to frame once for all operations @@ -514,7 +514,36 @@ def build_node_hierarchy(self, pandas_index: pd.Index) -> None: # All children loaded for node in self.node_map.values(): node.loaded_count = node.total_children() + + def apply_filter(self): + pandas_query = " & ".join(self.df_query.values()) + filtered_df = self.df.query(pandas_query) + self.reset_hierarchy(filtered_df) + + def apply_sort(self): + if self.df.empty: + return + + # Extract the unique order of higher levels + higher_levels = self.df.index.droplevel(-1).unique() if self.df.index.nlevels > 1 else [None] + + # Build a new index by sorting only within each higher level + sorted_index = [] + + for lvl in higher_levels: + mask = self.df.index.droplevel(-1) == lvl if lvl is not None else self.df.index + partial_df = self.df.loc[mask, self.sorted_column or self.df.columns[0]].copy() + if self.sorted_column is not None: + partial_df.sort_values(ascending=(self.sort_order == Qt.SortOrder.AscendingOrder), inplace=True) + else: + partial_df = partial_df.sort_index(ascending=(self.sort_order == Qt.SortOrder.AscendingOrder)) + sorted_index.append(partial_df.index) + + sorted_index = sorted_index[0].append(sorted_index[1:]) # Flatten + self.df = self.df.loc[sorted_index] # Update dataframe to new sorted order + + def values_from_indices(self, key: str, indices: list[QModelIndex]): """ Returns the values from the given indices. From f1c2eeab34a1faa54f7cfc6209d3cad64f9cbf00 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 1 Dec 2025 11:55:25 +0100 Subject: [PATCH 153/267] Fixed overlay error in dragdrop --- activity_browser/app/panes/database_products.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 0653d1e4b..afd664fa7 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -339,6 +339,7 @@ def __init__(self, parent: DatabaseProductsPane, db_name: str): self.pane = parent self.propertyDelegate = delegates.PropertyDelegate(self) + self.overlay = None def setDefaultColumnDelegates(self): """ @@ -444,8 +445,8 @@ def dragLeaveEvent(self, event): Args: event: The drag leave event. """ - # Reset the palette on drag leave - self.overlay.deleteLater() + if self.overlay: + self.overlay.deleteLater() def dropEvent(self, event): """ @@ -456,7 +457,8 @@ def dropEvent(self, event): """ logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") # Reset the palette on drop - self.overlay.deleteLater() + if self.overlay: + self.overlay.deleteLater() keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") keys = list(set(keys)) From e2b15ba5bc829db228624093d5b4eaf0aa8e20cd Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 1 Dec 2025 13:25:48 +0100 Subject: [PATCH 154/267] Fixed sorting on full search --- activity_browser/app/panes/database_products.py | 13 +++++++++++-- activity_browser/ui/core/tree_model.py | 4 ++-- activity_browser/ui/widgets/tree_view.py | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index afd664fa7..53bedd6eb 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -149,7 +149,13 @@ def sync(self): t = time() df = self.build_df() - self.model.set_dataframe(df) + if self.search_bar.toPlainText().strip(): + self.model.sorted_column = None # Reset sorting when searching + + if self.model.df.empty: + self.model.set_dataframe(df) + else: + self.model.update_dataframe(df) self.table_view.header().setHidden(self.simple) self.table_view.viewport().setBackgroundRole( @@ -179,7 +185,7 @@ def build_df(self) -> pd.DataFrame: t = time() cols = ["name", "key", "processor", "product", "type", "unit", "location", "id", "categories", "properties"] - query = self.search_bar.toPlainText() + query = self.search_bar.toPlainText().strip() if query: df = app.metadata.search_database(query, self.database.name, cols) else: @@ -504,6 +510,9 @@ def displayData(self, index: QModelIndex) -> any: row = self.row(index) + if row is None: + return None + # Get the product or name for title title = row.get("product") or row.get("name") diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 70331c485..aa83c07cb 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -191,10 +191,10 @@ def data(self, index: QModelIndex, role: int = Qt.DisplayRole): def displayData(self, index: QModelIndex) -> any: node = index.internalPointer() - + if not isinstance(node, TreeNode): return None - + if not node.is_leaf: # branch node # For branch nodes, show the name in the first column only # (spanning will be handled by the view) diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py index 57fde65ae..a4abd522a 100644 --- a/activity_browser/ui/widgets/tree_view.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -71,7 +71,6 @@ def __init__(self, parent=None): super().__init__(parent) self.setIndentation(10) - self.setUniformRowHeights(True) self.setItemDelegate(delegates.StringDelegate(self)) self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) @@ -96,6 +95,7 @@ def setModel(self, model): model.modelAboutToBeReset.connect(self.clearColumnDelegates) model.modelReset.connect(self.setDefaultColumnDelegates) + model.modelReset.connect(self.updateBranchSpanning, QtCore.Qt.ConnectionType.QueuedConnection) model.layoutChanged.connect(self.updateIndexColumnVisibility) model.layoutChanged.connect(self.updateBranchSpanning, QtCore.Qt.ConnectionType.QueuedConnection) From 362e05d52a0e14443bab46eccef7bc5f74a91678 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 1 Dec 2025 13:30:38 +0100 Subject: [PATCH 155/267] Fix branch spanning for inserted rows --- activity_browser/ui/widgets/tree_view.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py index a4abd522a..fc1c56142 100644 --- a/activity_browser/ui/widgets/tree_view.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -98,6 +98,7 @@ def setModel(self, model): model.modelReset.connect(self.updateBranchSpanning, QtCore.Qt.ConnectionType.QueuedConnection) model.layoutChanged.connect(self.updateIndexColumnVisibility) model.layoutChanged.connect(self.updateBranchSpanning, QtCore.Qt.ConnectionType.QueuedConnection) + model.rowsInserted.connect(self.updateBranchSpanningForInsertedRows, QtCore.Qt.ConnectionType.QueuedConnection) self.setDefaultColumnDelegates() self.updateIndexColumnVisibility() @@ -215,6 +216,26 @@ def updateBranchSpanning(self): # Recursively set spanning for all branch nodes self._setSpanningRecursive(QtCore.QModelIndex()) + def updateBranchSpanningForInsertedRows(self, parent: QtCore.QModelIndex, first: int, last: int): + """Update spanning for newly inserted rows during lazy loading.""" + model = self.model() + if model is None or not hasattr(model, 'isBranchNode'): + return + + # Set spanning for the newly inserted rows + for row in range(first, last + 1): + index = model.index(row, 0, parent) + if not index.isValid(): + continue + + # Check if this is a branch node + if model.isBranchNode(index): + self.setFirstColumnSpanned(row, parent, True) + # Recursively process children of this branch node + self._setSpanningRecursive(index) + else: + self.setFirstColumnSpanned(row, parent, False) + def _setSpanningRecursive(self, parent: QtCore.QModelIndex): """Recursively set first column spanning for branch nodes.""" model = self.model() From b9ea70f26a570f450dbb8f64e061dfe749a8c361 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 1 Dec 2025 14:40:02 +0100 Subject: [PATCH 156/267] Add nodes_to_excel function for exporting nodes as HTML table --- .../app/panes/database_products.py | 16 +++--------- activity_browser/bwutils/commontasks.py | 25 ++++++++++++++++++- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 53bedd6eb..073360454 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -9,7 +9,7 @@ from activity_browser import ui, app from activity_browser.ui import core, widgets, delegates, icons -from activity_browser.bwutils.commontasks import database_is_locked, database_is_legacy, is_node_biosphere +from activity_browser.bwutils.commontasks import database_is_locked, database_is_legacy, is_node_biosphere, nodes_to_excel NODETYPES = { @@ -596,16 +596,8 @@ def mimeData(self, indices: list[QtCore.QModelIndex]): keys = {key for key in keys if isinstance(key, tuple)} data.setPickleData("application/bw-nodekeylist", list(keys)) - # Add text data for Excel/external apps - # Get selected rows and build tab-separated text - rows = [self.row(i) for i in indices] - columns = [c for c in self.columns() if c not in ["index", "node"]] - text_lines = ["\t".join(columns)] # Header line - - for row in rows: - # Select relevant columns for export - text_lines.append("\t".join(str(row.get(col, "")) for col in columns)) - - data.setText("\n".join(text_lines)) + # Add HTML data for Excel with bold formatting + excel_string = nodes_to_excel(list(keys)) + data.setHtml(excel_string) return data diff --git a/activity_browser/bwutils/commontasks.py b/activity_browser/bwutils/commontasks.py index 0252e16c7..1440070f7 100644 --- a/activity_browser/bwutils/commontasks.py +++ b/activity_browser/bwutils/commontasks.py @@ -527,4 +527,27 @@ def get_templates() -> dict: if file.endswith(".tar.gz"): collection[file[:-7]] = os.path.join(template_dir, file) - return collection \ No newline at end of file + return collection + +def nodes_to_excel(nodes: list[tuple | int | bd.Node]) -> str: + """Convert a list of nodes to an HTML table suitable for Excel.""" + from .exporters import ABCSVFormatter + nodes = [refresh_node(n) for n in nodes] + databases = set(n["database"] for n in nodes) + if len(databases) > 1: + raise ValueError("All nodes must be from the same database") + db_name = databases.pop() + formatter = ABCSVFormatter(db_name, nodes) + data = formatter.get_formatted_data(sections=["activities", "exchanges"]) + + html_rows = [] + for row in data: + if isinstance(row, list): + # Bold formatting for lists with nowrap + cells = "".join(f'{str(i)}' for i in row) + else: + # Regular formatting for tuples with nowrap + cells = "".join(f'{str(i)}' for i in row) + html_rows.append(f"{cells}") + + return f"{''.join(html_rows)}
" From 8d5aa7f9616e18f7f4b962c16ef2e27d84be8a11 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 1 Dec 2025 14:55:22 +0100 Subject: [PATCH 157/267] Fixed quick search being out of order --- activity_browser/app/panes/database_products.py | 9 ++++----- activity_browser/ui/core/tree_model.py | 2 -- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 073360454..075caa03b 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -150,12 +150,11 @@ def sync(self): df = self.build_df() if self.search_bar.toPlainText().strip(): - self.model.sorted_column = None # Reset sorting when searching + # Reset sorting when searching + self.model.sorted_column = None + self.model.sort_order = Qt.SortOrder.AscendingOrder - if self.model.df.empty: - self.model.set_dataframe(df) - else: - self.model.update_dataframe(df) + self.model.set_dataframe(df) self.table_view.header().setHidden(self.simple) self.table_view.viewport().setBackgroundRole( diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index aa83c07cb..6302b302a 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -541,8 +541,6 @@ def apply_sort(self): sorted_index = sorted_index[0].append(sorted_index[1:]) # Flatten self.df = self.df.loc[sorted_index] # Update dataframe to new sorted order - - def values_from_indices(self, key: str, indices: list[QModelIndex]): """ From dfd61c3afd09c6900aae3f4fc9676c6b28fbf8db Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 1 Dec 2025 16:07:17 +0100 Subject: [PATCH 158/267] Fixed some card display bugs --- .../app/panes/database_products.py | 20 ++++++++++++------- activity_browser/ui/delegates/card.py | 4 ++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 075caa03b..16643d853 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -553,15 +553,21 @@ def decorationData(self, index: QtCore.QModelIndex) -> any: if column_name not in ["name", "product", "node"]: return None - if column_name == "product" and node_type in ["product", "processwithreferenceproduct"]: - return icons.qicons.product - if column_name == "product" and node_type == "waste": - return icons.qicons.waste - if node_type == "processwithreferenceproduct": + + if column_name == "name" and node_type in ["product", "waste"]: + return icons.qicons.process + if column_name in ["name", "node"] and node_type == "processwithreferenceproduct": return icons.qicons.processproduct - if node_type in NODETYPES["biosphere"]: + if column_name in ["name", "node"] and node_type in NODETYPES["biosphere"]: return icons.qicons.biosphere - return icons.qicons.process + if column_name == "name": + return icons.qicons.empty + + if column_name in ["product", "node"] and node_type in ["product", "processwithreferenceproduct"]: + return icons.qicons.product + if column_name in ["product", "node"] and node_type == "waste": + return icons.qicons.waste + return icons.qicons.empty def toolTipData(self, index: QtCore.QModelIndex) -> str: column_name = self.column_name(index) diff --git a/activity_browser/ui/delegates/card.py b/activity_browser/ui/delegates/card.py index 542fe8857..3e4401385 100644 --- a/activity_browser/ui/delegates/card.py +++ b/activity_browser/ui/delegates/card.py @@ -30,7 +30,7 @@ def sizeHint(self, option, index): line_height = fm.height() # Title (2 lines, larger font) - title_height = int(line_height * 1.3 * self.TITLE_LINES) + 5 # 1.3x for larger font + title_height = int(line_height * 1 * self.TITLE_LINES) + 5 # 1.3x for larger font # Subtitle subtitle_height = int(line_height * 0.9) # 0.9x for smaller font @@ -85,7 +85,7 @@ def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): # Draw title (bold, larger, 2 lines) title = card_data.get('title', '') title_font = option.font - title_font.setPointSize(int(option.font.pointSize() * 1.3)) + title_font.setPointSize(int(option.font.pointSize() * 1)) title_font.setWeight(QtGui.QFont.Weight.DemiBold) painter.setFont(title_font) painter.setPen(option.palette.text().color()) From 3658bbe2277e5ede7da12e8e73f7ecf1e571599c Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 1 Dec 2025 16:11:03 +0100 Subject: [PATCH 159/267] Fixed broken exchange context menu --- activity_browser/app/pages/activity_details/exchanges_tab.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index 8c57351c7..ce1f874b3 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -431,7 +431,8 @@ def activity(self): @property def exchanges(self): indexes = self.parent().selectedIndexes() - return list(set(idx.internalPointer().exchange for idx in indexes if idx.isValid())) + exchanges = [i.model().get(i, "_exchange") for i in indexes] + return list(set(exchanges)) def __init__(self, parent): """ From a5bc3844ad514922bc2901be0506932988aba2cb Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 2 Dec 2025 10:27:32 +0100 Subject: [PATCH 160/267] Fixed broken exchange context menu --- .../app/actions/exchange/exchange_new.py | 4 +- .../pages/activity_details/exchanges_tab.py | 138 ++++++++++++++++-- activity_browser/ui/widgets/drop_overlay.py | 51 ++++++- 3 files changed, 170 insertions(+), 23 deletions(-) diff --git a/activity_browser/app/actions/exchange/exchange_new.py b/activity_browser/app/actions/exchange/exchange_new.py index 6f3dd247c..454ce537a 100644 --- a/activity_browser/app/actions/exchange/exchange_new.py +++ b/activity_browser/app/actions/exchange/exchange_new.py @@ -16,8 +16,8 @@ class ExchangeNew(ABAction): @staticmethod @exception_dialogs - def run(from_keys: List[tuple], to_key: tuple, type: str): + def run(from_keys: List[tuple], to_key: tuple, type: str, amount: float = 1): to_activity = bd.get_activity(to_key) for from_key in from_keys: - exchange = to_activity.new_exchange(input=from_key, type=type, amount=1) + exchange = to_activity.new_exchange(input=from_key, type=type, amount=amount) exchange.save() diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index ce1f874b3..684d8787e 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -1,4 +1,5 @@ from loguru import logger +from typing import Literal from qtpy import QtWidgets, QtGui, QtCore from qtpy.QtCore import Qt @@ -8,7 +9,7 @@ import bw_functional as bf -from activity_browser import app, app +from activity_browser import app from activity_browser.bwutils.commontasks import refresh_node, database_is_locked, database_is_legacy, is_node_product, is_node_biosphere, parameters_in_scope from activity_browser.ui import widgets, icons, delegates, core @@ -102,15 +103,20 @@ def sync(self) -> None: production = self.activity.production() technosphere = self.activity.technosphere() biosphere = self.activity.biosphere() + substitution = self.activity.substitution() # Filter inputs and outputs based on the amount and type inputs = ([x for x in production if x["amount"] < 0] + [x for x in technosphere if x["amount"] >= 0] + - [x for x in biosphere if (x.input["type"] != "emission" and x["amount"] >= 0) or (x.input["type"] == "emission" and x["amount"] < 0)]) + [x for x in biosphere if (x.input["type"] != "emission" and x["amount"] >= 0) or (x.input["type"] == "emission" and x["amount"] < 0)] + + [x for x in substitution if x["amount"] < 0] + ) outputs = ([x for x in production if x["amount"] >= 0] + [x for x in technosphere if x["amount"] < 0] + - [x for x in biosphere if (x.input["type"] == "emission" and x["amount"] >= 0) or (x.input["type"] != "emission" and x["amount"] < 0)]) + [x for x in biosphere if (x.input["type"] == "emission" and x["amount"] >= 0) or (x.input["type"] != "emission" and x["amount"] < 0)] + + [x for x in substitution if x["amount"] >= 0] + ) # Update the models with the new data output_df = self.build_df(outputs) @@ -197,10 +203,59 @@ def dragEnterEvent(self, event): if database_is_locked(self.activity["database"]): return - if event.mimeData().hasFormat("application/bw-nodekeylist"): - self.overlay = widgets.ABDropOverlay(self) - self.overlay.show() - event.accept() + if not event.mimeData().hasFormat("application/bw-nodekeylist"): + return + + event.accept() + action = self.action_from_mime(event.mimeData()) + + self.input_view.overlay.show() + self.output_view.overlay.show() + + if action == "product": + self.output_view.overlay.setText("Drop to substitute production") + self.input_view.overlay.setText("Drop to consume product") + return + + if action == "waste": + self.output_view.overlay.setText("Drop to produce waste") + self.input_view.overlay.setText("Drop to substitute waste consumption") + return + + if action == "resource": + self.output_view.overlay.hide() + self.input_view.overlay.setText("Drop to consume natural resource") + return + + if action == "emission": + self.input_view.overlay.hide() + self.output_view.overlay.setText("Drop to emit to environment") + return + + + def dragMoveEvent(self, event): + """ + Handles the drag move event to adjust overlay opacity based on hover position. + + Args: + event: The drag move event. + """ + if not event.mimeData().hasFormat("application/bw-nodekeylist"): + return + + if self.input_view.overlay.hovering(): + self.input_view.overlay.setOpacity("high") + self.output_view.overlay.setOpacity("medium") + elif self.output_view.overlay.hovering(): + self.output_view.overlay.setOpacity("high") + self.input_view.overlay.setOpacity("medium") + else: + self.input_view.overlay.setOpacity("medium") + self.output_view.overlay.setOpacity("medium") + event.ignore() + return + + event.accept() def dragLeaveEvent(self, event): """ @@ -210,7 +265,8 @@ def dragLeaveEvent(self, event): event: The drag leave event. """ # Reset the palette on drag leave - self.overlay.deleteLater() + self.input_view.overlay.hide() + self.output_view.overlay.hide() def dropEvent(self, event): """ @@ -220,21 +276,55 @@ def dropEvent(self, event): event: The drop event. """ logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") - # Reset the palette on drop - self.overlay.deleteLater() + self.input_view.overlay.hide() + self.output_view.overlay.hide() + + output = self.output_view.overlay.hovering() keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - exchanges = {"technosphere": set(), "biosphere": set()} + exchanges = {"technosphere": set(), "biosphere": set(), "substitution": set()} for key in keys: - if exc_type := get_exchange_type(key): + if exc_type := get_exchange_type(key, output=output): exchanges[exc_type].add(key) # Run the action for new exchanges for exc_type, keys in exchanges.items(): app.actions.ExchangeNew.run(keys, self.activity.key, exc_type) -def get_exchange_type(activity_key: tuple) -> str | None: + def action_from_mime(self, mime: core.ABMimeData) -> Literal["product", "waste", "resource", "emission", "generic"]: + """ + Determines the appropriate action based on the mime data. + + Args: + mime (core.ABMimeData): The mime data. + + """ + keys = mime.retrievePickleData("application/bw-nodekeylist") + data = app.metadata.get_metadata(keys, ["type"]) + data = set(data["type"].unique()) + data.discard("process") + data.discard("multifunctional") + data.discard("nonfunctional") + + if len(data) != 1: + return "generic" + + node_type = data.pop() + if node_type in ["product", "processwithreferenceproduct"]: + return "product" + if node_type == "waste": + return "waste" + if node_type == "natural resource": + return "resource" + if node_type == "emission": + return "emission" + else: + return "generic" + +def get_exchange_type(activity_key: tuple, output=False) -> str | None: + if output and is_node_product(activity_key): + return "substitution" if is_node_product(activity_key): return "technosphere" elif is_node_biosphere(activity_key): @@ -458,6 +548,8 @@ def __init__(self, parent): # Set the property delegate self.propertyDelegate = delegates.PropertyDelegate(self) + self.overlay = widgets.ABDropOverlay(self) + self.overlay.hide() @property def activity(self): @@ -483,6 +575,8 @@ def setDefaultColumnDelegates(self): self.setItemDelegateForColumn(i, self.propertyDelegate) + + class ExchangesModel(core.ABTreeModel): """ A model representing the data for the exchanges. @@ -591,6 +685,12 @@ def fontData(self, index: QtCore.QModelIndex) -> any: Returns: QtGui.QFont: The font data for the index. """ + if self.substituted(index): + font = QtGui.QFont() + font.setItalic(True) + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font + if self.functional(index): font = QtGui.QFont() font.setWeight(QtGui.QFont.Weight.DemiBold) @@ -641,6 +741,18 @@ def functional(self, index): bool: True if the index is functional, False otherwise. """ return self.get(index, "_exchange_type") == "production" + + def substituted(self, index): + """ + Returns whether the index is functional. + + Args: + index (QtCore.QModelIndex): The index to check. + + Returns: + bool: True if the index is functional, False otherwise. + """ + return self.get(index, "_exchange_type") == "substitution" def scoped_parameters(self, index): """ diff --git a/activity_browser/ui/widgets/drop_overlay.py b/activity_browser/ui/widgets/drop_overlay.py index 3c702c926..66ab1ce71 100644 --- a/activity_browser/ui/widgets/drop_overlay.py +++ b/activity_browser/ui/widgets/drop_overlay.py @@ -1,25 +1,60 @@ +from typing import Literal + from qtpy import QtWidgets, QtGui from qtpy.QtCore import Qt class ABDropOverlay(QtWidgets.QWidget): + opacityMap = { + "low": 100, + "medium": 150, + "high": 200, + } + def __init__(self, parent=None, text="Drop here to create new exchanges"): super().__init__(parent) - self.setAttribute(Qt.WA_TransparentForMouseEvents) - self.setAttribute(Qt.WA_NoSystemBackground) - self.setAttribute(Qt.WA_TranslucentBackground) + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAutoFillBackground(False) self.resize(parent.size()) - self.text = text + + self._text = text + self._opacity: Literal["low", "medium", "high"] = "medium" + + def hovering(self) -> bool: + cursor_pos = QtGui.QCursor.pos() + widget_rect = self.rect() + local_pos = self.mapFromGlobal(cursor_pos) + return widget_rect.contains(local_pos) + + def setOpacity(self, level: Literal["low", "medium", "high"]): + if level in self.opacityMap: + self._opacity = level + self.update() + + def opacity(self): + return self._opacity + + def text(self): + return self._text + + def setText(self, text: str): + self._text = text + self.update() + + def showEvent(self, event): + self.resize(self.parent().size()) + super().showEvent(event) def paintEvent(self, event): painter = QtGui.QPainter(self) - painter.setRenderHint(QtGui.QPainter.Antialiasing) - painter.fillRect(self.rect(), QtGui.QColor(0, 100, 255, 200)) # Semi-transparent blue - painter.setPen(Qt.white) + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + painter.fillRect(self.rect(), QtGui.QColor(0, 100, 255, self.opacityMap[self.opacity()])) # Semi-transparent blue + painter.setPen(Qt.GlobalColor.white) font = self.font() font.setBold(True) painter.setFont(font) - painter.drawText(self.rect(), Qt.AlignCenter, self.text) + painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self.text()) From 0999733a9fec435a0efc22faee1941ed7a3ea1ce Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 2 Dec 2025 12:15:20 +0100 Subject: [PATCH 161/267] Caching the MDS --- .../app/panes/impact_categories.py | 11 ---- activity_browser/app/signalling.py | 10 ++-- activity_browser/bwutils/filesystem.py | 4 +- activity_browser/bwutils/metadata/loader.py | 55 +++++++++++++++++++ activity_browser/bwutils/metadata/metadata.py | 20 +++++++ activity_browser/bwutils/metadata/updater.py | 2 +- 6 files changed, 82 insertions(+), 20 deletions(-) diff --git a/activity_browser/app/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py index e2436591e..2c530805b 100644 --- a/activity_browser/app/panes/impact_categories.py +++ b/activity_browser/app/panes/impact_categories.py @@ -31,7 +31,6 @@ def __init__(self, parent=None): self.build_layout() self.connect_signals() - self.load() def build_layout(self): layout = QtWidgets.QVBoxLayout() @@ -43,18 +42,8 @@ def build_layout(self): def connect_signals(self): app.signals.meta.methods_changed.connect(self.sync) - app.signals.project.changed.connect(self.sync) app.signals.database_read_only_changed.connect(self.sync) - def load(self): - df = self.build_df() - self.model.set_dataframe(df) - self.model.group(["_method_name"]) - # self.view.setColumnHidden(1, True) - # self.view.setColumnHidden(2, True) - # self.view.setColumnHidden(3, True) - # self.view.sortByColumn(1, Qt.SortOrder.AscendingOrder) - def sync(self): df = self.build_df() self.model.set_dataframe(df) diff --git a/activity_browser/app/signalling.py b/activity_browser/app/signalling.py index 4e8aeadac..73807a272 100644 --- a/activity_browser/app/signalling.py +++ b/activity_browser/app/signalling.py @@ -64,15 +64,13 @@ def __init__(self, parent=None): self._flusher.start() def _flush_metadata(self): - if not (self._metadata._added or self._metadata._updated or self._metadata._deleted): + added, updated, deleted = self._metadata.flush_mutations() + + if not (added or updated or deleted): return t = time() - self.synced.emit(self._metadata._added, self._metadata._updated, self._metadata._deleted) - - self._metadata._added.clear() - self._metadata._updated.clear() - self._metadata._deleted.clear() + self.synced.emit(added, updated, deleted) logger.debug(f"Metadatastore sync signal completed in {time() - t:.2f} seconds") diff --git a/activity_browser/bwutils/filesystem.py b/activity_browser/bwutils/filesystem.py index 30370872a..7dec94291 100644 --- a/activity_browser/bwutils/filesystem.py +++ b/activity_browser/bwutils/filesystem.py @@ -15,11 +15,11 @@ def get_appdata_path() -> Path: return path def get_project_path() -> Path: - path = bd.projects._base_data_dir + path = bd.projects.dir path.mkdir(parents=True, exist_ok=True) return path def get_project_ab_path() -> Path: - path = Path(bd.projects._base_data_dir) / "activity_browser" + path = Path(bd.projects.dir) / "activity_browser" path.mkdir(parents=True, exist_ok=True) return path diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index e5b40219c..b1e70c9a7 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -36,6 +36,11 @@ def load_project(self): self.primary_status = "loading" self.secondary_status = "loading" + # check for valid cache and load from it if available + if self._has_valid_cache(): + self.cache_load_project() + return + # start loading thread for secondary metadata thread = SecondaryLoadThread( databases=list(bd.databases), @@ -47,6 +52,22 @@ def load_project(self): # load primary metadata in the main thread self.primary_load_project() + def cache_load_project(self): + from activity_browser.bwutils import filesystem + logger.debug("Loading metadata from cache") + + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + self.mds.dataframe = pd.read_pickle(cache_path) + + for idx in self.mds.dataframe.index: + self.mds.register_mutation(idx, "add") + + self.primary_status = "done" + self.secondary_status = "done" + + thread = threading.Thread(target=self._init_searcher) + thread.start() + def primary_load_project(self): from bw2data.backends import sqlite3_lci_db @@ -145,8 +166,42 @@ def _fix_categories(self, df: pd.DataFrame): def _init_searcher(self): from .searcher import MDSSearcher + + if hasattr(self.mds, 'searcher') and self.mds.searcher is not None: + old_searcher = self.mds.searcher + self.mds.searcher = None + + # Clear large data structures + if hasattr(old_searcher, 'df'): + del old_searcher.df + if hasattr(old_searcher, 'identifier_to_word'): + del old_searcher.identifier_to_word + if hasattr(old_searcher, 'word_to_identifier'): + del old_searcher.word_to_identifier + if hasattr(old_searcher, 'word_to_q_grams'): + del old_searcher.word_to_q_grams + if hasattr(old_searcher, 'q_gram_to_word'): + del old_searcher.q_gram_to_word + + del old_searcher + self.mds.searcher = MDSSearcher(self.mds) + def _has_valid_cache(self) -> bool: + from activity_browser.bwutils import filesystem + + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + lci_path = filesystem.get_project_path() / "lci" / "databases.db" + + if not cache_path.exists() or not lci_path.exists(): + return False + + cache_mtime = cache_path.stat().st_mtime + lci_mtime = lci_path.stat().st_mtime + + return cache_mtime >= lci_mtime + + class SecondaryLoadThread(threading.Thread): """Thread for loading secondary metadata using multiprocessing Pool.""" diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index f27f879df..e4e1c009f 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -75,6 +75,25 @@ def register_mutation(self, key: tuple[str, str], action: Literal["add", "update else: raise ValueError(f"Unknown action: {action}") + def flush_mutations(self) -> tuple[set[tuple[str, str]], set[tuple[str, str]], set[tuple[str, str]]]: + from activity_browser.bwutils import filesystem + + if not (self._added or self._updated or self._deleted): + return set(), set(), set() + + added = self._added.copy() + updated = self._updated.copy() + deleted = self._deleted.copy() + + self._added.clear() + self._updated.clear() + self._deleted.clear() + + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + self.dataframe.to_pickle(cache_path) + + return added, updated, deleted + def match(self, **kwargs: dict[str, str]) -> pd.DataFrame: """Return a slice of the dataframe matching the criteria. """ @@ -146,6 +165,7 @@ def auto_complete(self, word: str, context: Optional[set] = None, database: Opti completions = self.searcher.auto_complete(word, context=context, database=database) return completions + def get_query_parameters(query: str) -> tuple[dict[str, str], str]: """Extract key-value pairs from a query string of the form 'key1:value1 key2:value2'.""" params = {} diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index 907e25579..4d4e0b8a0 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -9,7 +9,7 @@ -class MDSUpdater(): +class MDSUpdater: def __init__(self, mds: MetaDataStore): self.mds = mds self.connect_signals() From 569a37588fdc87b6c29e62f8d9a8bd49fba4ec1e Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Tue, 2 Dec 2025 12:25:40 +0100 Subject: [PATCH 162/267] node updates now register in searchindex --- activity_browser/bwutils/metadata/fields.py | 7 +++++++ activity_browser/bwutils/metadata/updater.py | 21 +++++++++++++++++-- activity_browser/bwutils/searchengine/base.py | 12 ++++++----- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/activity_browser/bwutils/metadata/fields.py b/activity_browser/bwutils/metadata/fields.py index 59b885928..3f4d72590 100644 --- a/activity_browser/bwutils/metadata/fields.py +++ b/activity_browser/bwutils/metadata/fields.py @@ -18,6 +18,13 @@ "allocation_factor": float, "properties": object, } + +search_engine_whitelist = [ + "id", "name", "synonyms", "unit", "key", "database", # generic + "CAS number", "categories", # biosphere specific + "product", "reference product", "classifications", "location", "properties" # activity specific + ] + all_types = {**primary_types, **secondary_types} primary = list(primary_types.keys()) diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index 907e25579..63ad397ee 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -4,7 +4,7 @@ import numpy as np from .metadata import MetaDataStore -from .fields import primary, secondary, all_types +from .fields import primary, secondary, all_types, search_engine_whitelist @@ -53,7 +53,7 @@ def on_signaleddataset_delete(self, sender, old): try: # Create a Series with the key to match the delete_node signature - ds = pd.Series({"key": old.key}, name=old.key) + ds = pd.Series({"key": old.key, "id": old.id}, name=old.key) self.delete_node(ds) except KeyError: pass @@ -81,15 +81,32 @@ def modify_node(self, ds: pd.Series): self.mds.dataframe.loc[ds.key] = ds self.mds.register_mutation(ds.key, "update") + if hasattr(self.mds, "searcher"): + search_engine_cols = list( + set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns + data = pd.DataFrame([ds[search_engine_cols]]) + self.mds.searcher.change_identifier(identifier=ds["id"], data=data) + def add_node(self, ds: pd.Series): self._fix_categories(ds) self.mds.dataframe.loc[ds.key, :] = ds self.mds.register_mutation(ds.key, "add") + if hasattr(self.mds, "searcher"): + search_engine_cols = list( + set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns + data = pd.DataFrame([ds[search_engine_cols]]) + self.mds.searcher.add_identifier(data=data) + def delete_node(self, ds: pd.Series): self.mds.dataframe = self.mds.dataframe.drop(ds.key) self.mds.register_mutation(ds.key, "delete") + if hasattr(self.mds, "searcher"): + id = ds["id"] + self.mds.searcher.remove_identifier(identifier=id) + self.mds.searcher.reset_all_caches(ds["database"]) + # database methods def add_database(self, db_name: str): self.mds.loader.load_database(db_name) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index 52116ab96..ee34e92ea 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -307,10 +307,11 @@ def add_identifier(self, data: pd.DataFrame) -> None: # update the search index data self.update_index(data) - def remove_identifier(self, identifier) -> None: + def remove_identifier(self, identifier, logging=True) -> None: """Remove this identifier from self.df and the search index. """ - t = time() + if logging: + t = time() # make sure the identifier exists if identifier not in self.df.index.to_list(): @@ -346,8 +347,9 @@ def remove_identifier(self, identifier) -> None: # finally, remove the identifier del self.identifier_to_word[identifier] - logger.debug(f"Search index updated in {time() - t:.2f} seconds " - f"for 1 removed item ({len(self.df)} items ({self.size_of_index()}) currently).") + if logging: + logger.debug(f"Search index updated in {time() - t:.2f} seconds " + f"for 1 removed item ({len(self.df)} items ({self.size_of_index()}) currently).") def change_identifier(self, identifier, data: pd.DataFrame) -> None: """Change this identifier. @@ -381,7 +383,7 @@ def change_identifier(self, identifier, data: pd.DataFrame) -> None: update_data[col] = [value] # remove the entry - self.remove_identifier(identifier, loggerging=False) + self.remove_identifier(identifier, logging=False) # add entry with updated data self.add_identifier(update_data) From b99d923e0f23358fc984db1067962573a7b24bdc Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 2 Dec 2025 14:36:52 +0100 Subject: [PATCH 163/267] Quicker tables --- .../parameterized_exchanges_section.py | 7 +-- .../pages/parameters/parameters_section.py | 9 +-- .../app/pages/settings/project_manager.py | 6 +- .../app/panes/calculation_setups.py | 3 +- .../app/panes/database_products.py | 60 +++++++------------ activity_browser/app/panes/databases.py | 4 +- .../app/panes/impact_categories.py | 3 +- activity_browser/ui/core/tree_model.py | 14 +++-- activity_browser/ui/delegates/card.py | 4 +- activity_browser/ui/widgets/tree_view.py | 1 + 10 files changed, 45 insertions(+), 66 deletions(-) diff --git a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py index 08c2ddc92..75da1dab0 100644 --- a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py +++ b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py @@ -54,16 +54,15 @@ def connect_signals(self): app.signals.parameter.changed.connect(self.sync) app.signals.parameter.recalculated.connect(self.sync) app.signals.parameter.deleted.connect(self.sync) - app.signals.project.changed.connect(self.sync) - app.signals.meta.databases_changed.connect(self.sync) + # app.signals.project.changed.connect(self.sync) + # app.signals.meta.databases_changed.connect(self.sync) def sync(self): """ Synchronizes the widget with the current state of parameterized exchanges. """ df = self.build_exchanges_df() - df.reset_index(drop=True, inplace=True) - self.model.set_dataframe(df) + self.model.set_dataframe(df, sort=self.model.df.empty) def build_exchanges_df(self) -> pd.DataFrame: """ diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py index 273be0c71..ef7e3fbec 100644 --- a/activity_browser/app/pages/parameters/parameters_section.py +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -55,20 +55,17 @@ def connect_signals(self): Connects signals to their respective slots. """ app.signals.metadata.synced.connect(self.sync) + app.signals.parameter.changed.connect(self.sync) app.signals.parameter.recalculated.connect(self.sync) app.signals.parameter.deleted.connect(self.sync) - app.signals.project.changed.connect(self.sync) - app.signals.meta.databases_changed.connect(self.sync) def sync(self): """ Synchronizes the widget with the current state of parameters. """ df = self.build_df() - df.reset_index(drop=True, inplace=True) - self.model.set_dataframe(df) - self.model.group(["_param_type", "_scope"]) + self.model.set_dataframe(df, group=["_param_type", "_scope"], sort=self.model.df.empty) self.view.expandAll() self.view.resizeColumnToContents(1) @@ -91,7 +88,7 @@ def build_df(self) -> pd.DataFrame: # Database parameters for param in DatabaseParameter.select(): - row = self._parameter_to_row(param, "{param.database}", param.database) + row = self._parameter_to_row(param, f"{param.database}", param.database) translated.append(row) # Activity parameters diff --git a/activity_browser/app/pages/settings/project_manager.py b/activity_browser/app/pages/settings/project_manager.py index 6f49e7883..8d681978f 100644 --- a/activity_browser/app/pages/settings/project_manager.py +++ b/activity_browser/app/pages/settings/project_manager.py @@ -53,13 +53,11 @@ def connect_signals(self): def sync(self): """Sync project and template data.""" df = self.build_project_df() - df.reset_index(drop=True, inplace=True) - self.project_model.set_dataframe(df) + self.project_model.set_dataframe(df, sort=self.project_model.df.empty) self.project_view.resizeColumnToContents(1) df = self.build_template_df() - df.reset_index(drop=True, inplace=True) - self.template_model.set_dataframe(df) + self.template_model.set_dataframe(df, sort=self.template_model.df.empty) self.template_view.resizeColumnToContents(1) def reset(self): diff --git a/activity_browser/app/panes/calculation_setups.py b/activity_browser/app/panes/calculation_setups.py index b7a221ada..036ebb2a8 100644 --- a/activity_browser/app/panes/calculation_setups.py +++ b/activity_browser/app/panes/calculation_setups.py @@ -53,8 +53,7 @@ def sync(self): Synchronizes the model with the current state of the calculation setups. """ df = self.build_df() - df.reset_index(drop=True, inplace=True) - self.model.set_dataframe(df) + self.model.set_dataframe(df, sort=self.model.df.empty) self.view.resizeColumnToContents(0) def build_df(self) -> pd.DataFrame: diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 16643d853..8a4be0fd4 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -47,10 +47,11 @@ def __init__(self, parent, db_name: str): self.simple = True # initialize the model - self.model = ProductModel(parent=self, chunk_size=50) + self.model = ProductModel(parent=self, chunk_size=20) # Create the QTableView and set the model self.table_view = ProductView(self, db_name=db_name) + self.table_view.setUniformRowHeights(True) self.table_view.setModel(self.model) self.search_bar = widgets.MetaDataAutoCompleteTextEdit(self) @@ -119,8 +120,6 @@ def connect_signals(self): app.signals.metadata.synced.connect(self.on_metadata_changed) app.signals.database.deleted.connect(self.on_database_deleted) - self.table_view.filtered.connect(self.search_error) - self.search_bar.textChangedDebounce.connect(self.search) self.view_toggle.checkStateChanged.connect(self.on_mode_switch) def on_metadata_changed(self, added, updated, deleted): @@ -154,25 +153,37 @@ def sync(self): self.model.sorted_column = None self.model.sort_order = Qt.SortOrder.AscendingOrder - self.model.set_dataframe(df) + self.model.set_dataframe(df, sort=self.model.df.empty) + self.update_table_style() + self.update_column_visibility() + + logger.debug(f"Synced DatabaseProductsPane in {time() - t:.2f} seconds") + + def update_table_style(self): self.table_view.header().setHidden(self.simple) self.table_view.viewport().setBackgroundRole( QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) self.table_view.setFrameShape( QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) - for col in self.model.columns(): - if col == "index" or col == "node": + def update_column_visibility(self): + columns = self.model.columns() + df = self.model.df + + for index, col in enumerate(columns): + if col == "index": + continue + if col == "node": + self.table_view.setColumnHidden(index, not self.simple) continue - index = self.model.columns().index(col) if df[col].isna().all() or self.simple: self.table_view.hideColumn(index) else: self.table_view.showColumn(index) - logger.debug(f"Synced DatabaseProductsPane in {time() - t:.2f} seconds") + self.table_view.reset() def build_df(self) -> pd.DataFrame: """ @@ -208,9 +219,7 @@ def build_df(self) -> pd.DataFrame: df["node"] = None - cols = ["name", "product", "categories", "unit", "location", "key", "processor", "type"] - if self.simple: - cols += ["node"] + cols = ["name", "product", "categories", "unit", "location", "key", "processor", "type", "node"] cols += [col for col in df.columns if col.startswith("property")] cols += ["_id"] @@ -236,31 +245,8 @@ def on_mode_switch(self, check: Qt.CheckState): check (Qt.CheckState): The check state of the toggle. """ self.simple = check == Qt.CheckState.Unchecked - self.sync() - - def search_error(self, reset=False): - """ - Handles the search error by changing the search bar color. - - Args: - reset (bool, optional): Whether to reset the search bar color. Defaults to False. - """ - if reset: - self.search_bar.setPalette(app.application.palette()) - return - - palette = self.search_bar.palette() - palette.setColor(QtGui.QPalette.ColorRole.Base, QtGui.QColor(255, 128, 128)) - self.search_bar.setPalette(palette) - - def search(self, query: str): - """ - Applies the search query to the table view. - - Args: - query (str): The search query. - """ - self.sync() + self.update_table_style() + self.update_column_visibility() class ProductView(ui.widgets.ABTreeView): @@ -502,6 +488,7 @@ class ProductModel(ui.core.ABTreeModel): def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: return True + # -- data overrides --- def displayData(self, index: QModelIndex) -> any: column_name = self.column_name(index) if column_name != "node": @@ -546,7 +533,6 @@ def displayData(self, index: QModelIndex) -> any: "categories": categories if categories else None, } - #-- data overrides --- def decorationData(self, index: QtCore.QModelIndex) -> any: column_name = self.column_name(index) node_type = self.get(index, "type") diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py index 1c8f6d575..96bb10588 100644 --- a/activity_browser/app/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -46,7 +46,6 @@ def connect_signals(self): Connects the signals to the appropriate slots. """ app.signals.meta.databases_changed.connect(self.sync) - app.signals.project.changed.connect(self.sync) app.signals.database.deleted.connect(self.sync) app.signals.database_read_only_changed.connect(self.sync) @@ -64,8 +63,7 @@ def sync(self): Synchronizes the model with the current state of the databases. """ df = self.build_df() - df.reset_index(drop=True, inplace=True) - self.model.set_dataframe(df) + self.model.set_dataframe(df, sort=self.model.df.empty) self.view.resizeColumnToContents(1) self.view.header().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) diff --git a/activity_browser/app/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py index 2c530805b..69d0b2a0c 100644 --- a/activity_browser/app/panes/impact_categories.py +++ b/activity_browser/app/panes/impact_categories.py @@ -46,8 +46,7 @@ def connect_signals(self): def sync(self): df = self.build_df() - self.model.set_dataframe(df) - self.model.group(["_method_name"]) + self.model.set_dataframe(df, group=["_method_name"], sort=self.model.df.empty) def build_df(self): df = pd.DataFrame(bd.methods.values()) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 6302b302a..23db6d97e 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -335,22 +335,24 @@ def fetchMore(self, parent: QModelIndex) -> None: self.endInsertRows() # --- helper functions --- - def set_dataframe(self, df: pd.DataFrame) -> None: + def set_dataframe(self, df: pd.DataFrame, group: list[str] = None, sort = True) -> None: self.beginResetModel() self.df = df + self.grouped_columns = group or self.grouped_columns self.build_df_index() - self.apply_sort() - self.apply_filter() + sort and self.apply_sort() + sort and self.apply_filter() self.endResetModel() - def update_dataframe(self, df: pd.DataFrame) -> None: + def update_dataframe(self, df: pd.DataFrame, group: list[str] = None, sort = True) -> None: self.layoutAboutToBeChanged.emit() self.df = df + self.grouped_columns = group or self.grouped_columns self.build_df_index() - self.apply_sort() + sort and self.apply_sort() self.apply_filter() self.layoutChanged.emit() @@ -524,6 +526,8 @@ def apply_sort(self): if self.df.empty: return + print(f"Applying sorting in : {self.__class__.__name__}") + # Extract the unique order of higher levels higher_levels = self.df.index.droplevel(-1).unique() if self.df.index.nlevels > 1 else [None] diff --git a/activity_browser/ui/delegates/card.py b/activity_browser/ui/delegates/card.py index 3e4401385..58218e422 100644 --- a/activity_browser/ui/delegates/card.py +++ b/activity_browser/ui/delegates/card.py @@ -23,14 +23,12 @@ def sizeHint(self, option, index): if index.data() is None: return super().sizeHint(option, index) - card_data = index.data() - # Calculate text heights fm = option.fontMetrics line_height = fm.height() # Title (2 lines, larger font) - title_height = int(line_height * 1 * self.TITLE_LINES) + 5 # 1.3x for larger font + title_height = int(line_height * 1 * self.TITLE_LINES) + 5 # Subtitle subtitle_height = int(line_height * 0.9) # 0.9x for smaller font diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py index fc1c56142..a94ee9777 100644 --- a/activity_browser/ui/widgets/tree_view.py +++ b/activity_browser/ui/widgets/tree_view.py @@ -94,6 +94,7 @@ def setModel(self, model): self.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Fixed) model.modelAboutToBeReset.connect(self.clearColumnDelegates) + model.modelReset.connect(self.updateIndexColumnVisibility) model.modelReset.connect(self.setDefaultColumnDelegates) model.modelReset.connect(self.updateBranchSpanning, QtCore.Qt.ConnectionType.QueuedConnection) model.layoutChanged.connect(self.updateIndexColumnVisibility) From 7bebb3328cff03361311c4a9941937d5a452ced0 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 2 Dec 2025 14:41:02 +0100 Subject: [PATCH 164/267] Fix print statement --- activity_browser/ui/core/tree_model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 23db6d97e..9c77d7b95 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -1,7 +1,7 @@ from typing import Optional +from loguru import logger import pandas as pd -import numpy as np from PySide6 import QtGui from PySide6.QtCore import QModelIndex, Qt, QAbstractItemModel @@ -526,7 +526,7 @@ def apply_sort(self): if self.df.empty: return - print(f"Applying sorting in : {self.__class__.__name__}") + logger.debug(f"Applying sorting in : {self.__class__.__name__}") # Extract the unique order of higher levels higher_levels = self.df.index.droplevel(-1).unique() if self.df.index.nlevels > 1 else [None] From 542bfa91723c999038e9f433f606c565fd664fe8 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 2 Dec 2025 14:47:59 +0100 Subject: [PATCH 165/267] Setting excel data threaded because loading exchanges takes time --- activity_browser/app/panes/database_products.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 8a4be0fd4..1aa138bf1 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -1,5 +1,8 @@ +import threading + from loguru import logger from time import time +from threading import Thread import pandas as pd from qtpy import QtWidgets, QtCore, QtGui @@ -588,7 +591,15 @@ def mimeData(self, indices: list[QtCore.QModelIndex]): data.setPickleData("application/bw-nodekeylist", list(keys)) # Add HTML data for Excel with bold formatting - excel_string = nodes_to_excel(list(keys)) - data.setHtml(excel_string) + thread = threading.Thread(target=self.set_excel_nodes_threaded, args=(data, keys)) + thread.start() return data + + @staticmethod + def set_excel_nodes_threaded(data, keys): + excel_string = nodes_to_excel(list(keys)) + try: + data.setHtml(excel_string) + except RuntimeError: + pass From b21adb4a5f7b912c16ae4237c8b47fb03b73b29b Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 2 Dec 2025 15:27:17 +0100 Subject: [PATCH 166/267] Stop updater breaking the mds when an activity is emited as changed without mutations --- activity_browser/bwutils/metadata/updater.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index f435579a6..9964cbf3f 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -40,7 +40,7 @@ def on_signaleddataset_save(self, sender, old, new): if new.key in self.mds.dataframe.index and not all(node_data.dropna().eq(self.mds.dataframe.loc[new.key].dropna())): self.modify_node(node_data) - else: + elif new.key not in self.mds.dataframe.index: self.add_node(node_data) def on_signaleddataset_delete(self, sender, old): From 15d31585d5aa17c020ed475b96e654be59ea359e Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 2 Dec 2025 15:27:24 +0100 Subject: [PATCH 167/267] Set a new logs folder --- activity_browser/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index 3f88c55d0..922501cd5 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -118,7 +118,7 @@ def setup_logging(): logger.add(sys.stderr, level="DEBUG", colorize=True, format="{time:HH:mm:ss} | {level: <8} | {message}") - log_dir = platformdirs.user_log_dir("ActivityBrowser", "ActivityBrowser") + log_dir = platformdirs.user_log_dir(appname="ActivityBrowser", appauthor="pylca") os.makedirs(log_dir, exist_ok=True) log_file = os.path.join(log_dir, "activity_browser.log") logger.add(log_file, level="DEBUG", rotation="5 MB", retention=5) From 1252c62285ae596e4746e3586d6fdc6af2553a9f Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 2 Dec 2025 16:23:02 +0100 Subject: [PATCH 168/267] dataframe sync fix --- .../pages/activity_details/exchanges_tab.py | 59 +++++++++++++++++-- .../parameterized_exchanges_section.py | 2 +- .../pages/parameters/parameters_section.py | 2 +- .../app/pages/settings/project_manager.py | 4 +- .../app/panes/calculation_setups.py | 2 +- .../app/panes/database_products.py | 2 +- activity_browser/app/panes/databases.py | 3 +- .../app/panes/impact_categories.py | 2 +- activity_browser/ui/core/tree_model.py | 12 ++-- 9 files changed, 71 insertions(+), 17 deletions(-) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index 684d8787e..4276557dd 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -1,3 +1,4 @@ +from PySide6.QtCore import QModelIndex from loguru import logger from typing import Literal @@ -203,7 +204,10 @@ def dragEnterEvent(self, event): if database_is_locked(self.activity["database"]): return - if not event.mimeData().hasFormat("application/bw-nodekeylist"): + has_nodes = event.mimeData().hasFormat("application/bw-nodekeylist") + has_exchanges = event.mimeData().hasFormat("application/bw-exchangelist") + + if not has_nodes and not has_exchanges: return event.accept() @@ -240,7 +244,10 @@ def dragMoveEvent(self, event): Args: event: The drag move event. """ - if not event.mimeData().hasFormat("application/bw-nodekeylist"): + has_nodes = event.mimeData().hasFormat("application/bw-nodekeylist") + has_exchanges = event.mimeData().hasFormat("application/bw-exchangelist") + + if not has_nodes and not has_exchanges: return if self.input_view.overlay.hovering(): @@ -534,6 +541,12 @@ def __init__(self, parent): super().__init__(parent) self.setSortingEnabled(True) + # Enable drag and drop + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDragDropMode(QtWidgets.QAbstractItemView.DragDropMode.DragDrop) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + self.drag_drop_hint = QtWidgets.QLabel("Drag products here to create new exchanges.", self) fnt = self.drag_drop_hint.font() fnt.setPointSize(fnt.pointSize() + 2) @@ -543,7 +556,7 @@ def __init__(self, parent): # Set up the layout layout = QtWidgets.QVBoxLayout(self) layout.addStretch() - layout.addWidget(self.drag_drop_hint, alignment=Qt.AlignCenter) # Center horizontally + layout.addWidget(self.drag_drop_hint, alignment=Qt.AlignmentFlag.AlignCenter) # Center horizontally layout.addStretch() # Set the property delegate @@ -574,7 +587,17 @@ def setDefaultColumnDelegates(self): # Set the delegate for property columns self.setItemDelegateForColumn(i, self.propertyDelegate) + def startDrag(self, supportedActions: Qt.DropAction) -> None: + """ + Initiates a drag operation with the selected exchanges. + Args: + supportedActions: The supported drop actions. + """ + if database_is_locked(self.activity["database"]): + return + + super().startDrag(supportedActions) class ExchangesModel(core.ABTreeModel): @@ -584,7 +607,32 @@ class ExchangesModel(core.ABTreeModel): def __init__(self, tab: ExchangesTab): super().__init__(parent=tab) self.tab = tab - + + def mimeTypes(self) -> list[str]: + """ + Returns the list of MIME types that this model supports. + + Returns: + list[str]: List of supported MIME types. + """ + return ["application/bw-exchangelist"] + + def mimeData(self, indices: list[QtCore.QModelIndex]) -> core.ABMimeData: + """ + Returns the MIME data for the given indices. + + Args: + indices (list[QtCore.QModelIndex]): The indices to get the MIME data for. + + Returns: + core.ABMimeData: The MIME data containing the exchanges. + """ + data = core.ABMimeData() + exchanges = [self.get(index, "_exchange") for index in indices if index.isValid() and index.column() == 0] + exchanges = [exc for exc in exchanges if exc is not None] + data.setPickleData("application/bw-exchangelist", exchanges) + return data + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: """ Sets the data for the given index. @@ -729,6 +777,9 @@ def indexEditable(self, index): return True return False + + def indexDragEnabled(self, index: QModelIndex) -> bool: + return True def functional(self, index): """ diff --git a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py index 75da1dab0..5a20f5663 100644 --- a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py +++ b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py @@ -62,7 +62,7 @@ def sync(self): Synchronizes the widget with the current state of parameterized exchanges. """ df = self.build_exchanges_df() - self.model.set_dataframe(df, sort=self.model.df.empty) + self.model.set_dataframe(df) def build_exchanges_df(self) -> pd.DataFrame: """ diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py index ef7e3fbec..8682e9f09 100644 --- a/activity_browser/app/pages/parameters/parameters_section.py +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -65,7 +65,7 @@ def sync(self): Synchronizes the widget with the current state of parameters. """ df = self.build_df() - self.model.set_dataframe(df, group=["_param_type", "_scope"], sort=self.model.df.empty) + self.model.set_dataframe(df, group=["_param_type", "_scope"]) self.view.expandAll() self.view.resizeColumnToContents(1) diff --git a/activity_browser/app/pages/settings/project_manager.py b/activity_browser/app/pages/settings/project_manager.py index 8d681978f..2b9ce63c0 100644 --- a/activity_browser/app/pages/settings/project_manager.py +++ b/activity_browser/app/pages/settings/project_manager.py @@ -53,11 +53,11 @@ def connect_signals(self): def sync(self): """Sync project and template data.""" df = self.build_project_df() - self.project_model.set_dataframe(df, sort=self.project_model.df.empty) + self.project_model.set_dataframe(df) self.project_view.resizeColumnToContents(1) df = self.build_template_df() - self.template_model.set_dataframe(df, sort=self.template_model.df.empty) + self.template_model.set_dataframe(df) self.template_view.resizeColumnToContents(1) def reset(self): diff --git a/activity_browser/app/panes/calculation_setups.py b/activity_browser/app/panes/calculation_setups.py index 036ebb2a8..61cd5861e 100644 --- a/activity_browser/app/panes/calculation_setups.py +++ b/activity_browser/app/panes/calculation_setups.py @@ -53,7 +53,7 @@ def sync(self): Synchronizes the model with the current state of the calculation setups. """ df = self.build_df() - self.model.set_dataframe(df, sort=self.model.df.empty) + self.model.set_dataframe(df) self.view.resizeColumnToContents(0) def build_df(self) -> pd.DataFrame: diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 1aa138bf1..11184d85b 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -156,7 +156,7 @@ def sync(self): self.model.sorted_column = None self.model.sort_order = Qt.SortOrder.AscendingOrder - self.model.set_dataframe(df, sort=self.model.df.empty) + self.model.set_dataframe(df) self.update_table_style() self.update_column_visibility() diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py index 96bb10588..a067ec667 100644 --- a/activity_browser/app/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -46,6 +46,7 @@ def connect_signals(self): Connects the signals to the appropriate slots. """ app.signals.meta.databases_changed.connect(self.sync) + app.signals.metadata.synced.connect(self.sync) app.signals.database.deleted.connect(self.sync) app.signals.database_read_only_changed.connect(self.sync) @@ -63,7 +64,7 @@ def sync(self): Synchronizes the model with the current state of the databases. """ df = self.build_df() - self.model.set_dataframe(df, sort=self.model.df.empty) + self.model.set_dataframe(df) self.view.resizeColumnToContents(1) self.view.header().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) diff --git a/activity_browser/app/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py index 69d0b2a0c..f15a5763d 100644 --- a/activity_browser/app/panes/impact_categories.py +++ b/activity_browser/app/panes/impact_categories.py @@ -46,7 +46,7 @@ def connect_signals(self): def sync(self): df = self.build_df() - self.model.set_dataframe(df, group=["_method_name"], sort=self.model.df.empty) + self.model.set_dataframe(df, group=["_method_name"]) def build_df(self): df = pd.DataFrame(bd.methods.values()) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 9c77d7b95..51f408145 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -335,24 +335,26 @@ def fetchMore(self, parent: QModelIndex) -> None: self.endInsertRows() # --- helper functions --- - def set_dataframe(self, df: pd.DataFrame, group: list[str] = None, sort = True) -> None: + def set_dataframe(self, df: pd.DataFrame, group: list[str] = None) -> None: self.beginResetModel() + first_init = len(self.df.columns) == 0 # detect first init, don't sort or filter because the view will do it anyway + self.df = df self.grouped_columns = group or self.grouped_columns self.build_df_index() - sort and self.apply_sort() - sort and self.apply_filter() + first_init or self.apply_sort() + first_init or self.apply_filter() self.endResetModel() - def update_dataframe(self, df: pd.DataFrame, group: list[str] = None, sort = True) -> None: + def update_dataframe(self, df: pd.DataFrame, group: list[str] = None) -> None: self.layoutAboutToBeChanged.emit() self.df = df self.grouped_columns = group or self.grouped_columns self.build_df_index() - sort and self.apply_sort() + self.apply_sort() self.apply_filter() self.layoutChanged.emit() From 2242ab1a963e337b5f9b011c89bfb697c5bcd66a Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 2 Dec 2025 16:33:33 +0100 Subject: [PATCH 169/267] dataframe sync fix --- activity_browser/ui/core/tree_model.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 51f408145..3450cbf21 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -62,6 +62,7 @@ def __init__(self, df: pd.DataFrame = None, parent: Optional[QWidget] = None, ch self.grouped_columns: list[str] = [] # list of columns currently used for grouping self.sorted_column: str | None = None self.sort_order = Qt.SortOrder.AscendingOrder + self.was_sorted = False self.lazy = chunk_size > 0 self.chunk_size = chunk_size @@ -337,14 +338,14 @@ def fetchMore(self, parent: QModelIndex) -> None: # --- helper functions --- def set_dataframe(self, df: pd.DataFrame, group: list[str] = None) -> None: self.beginResetModel() - first_init = len(self.df.columns) == 0 # detect first init, don't sort or filter because the view will do it anyway + first_init = not self.was_sorted # detect first init, don't sort or filter because the view will do it anyway self.df = df self.grouped_columns = group or self.grouped_columns self.build_df_index() - first_init or self.apply_sort() - first_init or self.apply_filter() + self.apply_sort() + self.apply_filter() self.endResetModel() @@ -380,6 +381,8 @@ def ungroup(self) -> None: self.layoutChanged.emit() def sort(self, column: int | str, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: + self.was_sorted = True + self.layoutAboutToBeChanged.emit() self.sorted_column = self.headerData(column) if isinstance(column, int) else column From 9eb3dfa8f6168852e36ae9d536f6fab038d59f56 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 4 Dec 2025 13:28:27 +0100 Subject: [PATCH 170/267] ID column to Int64 instead of int64 --- activity_browser/bwutils/metadata/fields.py | 2 +- activity_browser/bwutils/metadata/loader.py | 21 +++++++++++++++++--- activity_browser/bwutils/metadata/updater.py | 2 +- activity_browser/ui/icons.py | 1 - 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/activity_browser/bwutils/metadata/fields.py b/activity_browser/bwutils/metadata/fields.py index 3f4d72590..95cc7639f 100644 --- a/activity_browser/bwutils/metadata/fields.py +++ b/activity_browser/bwutils/metadata/fields.py @@ -1,6 +1,6 @@ primary_types = { "key": object, - "id": "int64", + "id": "Int64", "code": str, "database": "category", "location": "category", diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index b1e70c9a7..7d95fe573 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -37,7 +37,7 @@ def load_project(self): self.secondary_status = "loading" # check for valid cache and load from it if available - if self._has_valid_cache(): + if self._has_cache(): self.cache_load_project() return @@ -54,10 +54,25 @@ def load_project(self): def cache_load_project(self): from activity_browser.bwutils import filesystem + import bw2data as bd + logger.debug("Loading metadata from cache") cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" - self.mds.dataframe = pd.read_pickle(cache_path) + cached_df = pd.read_pickle(cache_path) + + # quick sanity checks + try: + assert all(db in bd.databases for db in cached_df["database"].unique()) + assert len(cached_df) == len(cached_df["id"].unique()) + assert not cached_df.empty + except AssertionError: + logger.warning("Cache file is invalid or outdated, loading from database instead") + cache_path.unlink() + self.load_project() + return + + self.mds.dataframe = cached_df for idx in self.mds.dataframe.index: self.mds.register_mutation(idx, "add") @@ -187,7 +202,7 @@ def _init_searcher(self): self.mds.searcher = MDSSearcher(self.mds) - def _has_valid_cache(self) -> bool: + def _has_cache(self) -> bool: from activity_browser.bwutils import filesystem cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index 9964cbf3f..f2accccaa 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -4,7 +4,7 @@ import numpy as np from .metadata import MetaDataStore -from .fields import primary, secondary, all_types, search_engine_whitelist +from .fields import primary, secondary, all, all_types, search_engine_whitelist diff --git a/activity_browser/ui/icons.py b/activity_browser/ui/icons.py index b321d3c8c..c4d7c40db 100644 --- a/activity_browser/ui/icons.py +++ b/activity_browser/ui/icons.py @@ -4,7 +4,6 @@ from qtpy.QtCore import Qt, QSize from qtpy.QtGui import QIcon, QPixmap -from activity_browser.bwutils.strategies import link_exchanges_without_db PACKAGE_DIR = Path(__file__).resolve().parents[1] From af19851c01d340a44984b8688077b8a5865e3534 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 4 Dec 2025 13:54:03 +0100 Subject: [PATCH 171/267] Cached metadata checks --- activity_browser/bwutils/metadata/loader.py | 36 +++++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 7d95fe573..97a597c7a 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -54,7 +54,6 @@ def load_project(self): def cache_load_project(self): from activity_browser.bwutils import filesystem - import bw2data as bd logger.debug("Loading metadata from cache") @@ -62,12 +61,8 @@ def cache_load_project(self): cached_df = pd.read_pickle(cache_path) # quick sanity checks - try: - assert all(db in bd.databases for db in cached_df["database"].unique()) - assert len(cached_df) == len(cached_df["id"].unique()) - assert not cached_df.empty - except AssertionError: - logger.warning("Cache file is invalid or outdated, loading from database instead") + if not self._cache_check(cached_df): + logger.info("Cache file is invalid or outdated, loading from database instead") cache_path.unlink() self.load_project() return @@ -216,6 +211,33 @@ def _has_cache(self) -> bool: return cache_mtime >= lci_mtime + def _cache_check(self, cached_df: pd.DataFrame) -> bool: + import bw2data as bd + from bw2data.backends import sqlite3_lci_db + + if not all(db in bd.databases for db in cached_df["database"].unique()): + logger.warning("Cache file contains databases not in the current Brightway project") + return False + + if not len(cached_df) == len(cached_df["id"].unique()): + logger.warning("Cache file contains duplicate IDs") + return False + + if cached_df.empty: + logger.warning("Cache file is empty") + return False + + with sqlite3.connect(sqlite3_lci_db._filepath) as con: + cursor = con.cursor() + cursor.execute("SELECT COUNT(*) FROM activitydataset") + count = cursor.fetchone()[0] + + if count != len(cached_df): + logger.warning("Cache file row count does not match database row count") + return False + + return True + class SecondaryLoadThread(threading.Thread): From 198bf9a726d378966f1a112b94d5a6d6327b852e Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 4 Dec 2025 14:23:26 +0100 Subject: [PATCH 172/267] Make expensive model sorting opt-in --- .../app/pages/settings/project_manager.py | 5 ++--- activity_browser/app/panes/database_products.py | 2 +- activity_browser/ui/core/tree_model.py | 17 ++++++++++++----- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/activity_browser/app/pages/settings/project_manager.py b/activity_browser/app/pages/settings/project_manager.py index 2b9ce63c0..56bcd7cd6 100644 --- a/activity_browser/app/pages/settings/project_manager.py +++ b/activity_browser/app/pages/settings/project_manager.py @@ -22,8 +22,8 @@ def __init__(self, parent=None): self.tabs = QtWidgets.QTabWidget(self) - self.project_model = ProjectModel(parent=self) - self.template_model = TemplateModel(parent=self) + self.project_model = ProjectModel(parent=self, enable_sorting=True) + self.template_model = TemplateModel(parent=self, enable_sorting=True) self.project_view = ProjectView(self) self.project_view.setModel(self.project_model) @@ -36,7 +36,6 @@ def __init__(self, parent=None): self.build_layout() self.connect_signals() - self.reset() def build_layout(self): """Build the chapter layout.""" diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 11184d85b..b9c398d0e 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -50,7 +50,7 @@ def __init__(self, parent, db_name: str): self.simple = True # initialize the model - self.model = ProductModel(parent=self, chunk_size=20) + self.model = ProductModel(parent=self, chunk_size=20, enable_sorting=True) # Create the QTableView and set the model self.table_view = ProductView(self, db_name=db_name) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 3450cbf21..a91bb536b 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -52,7 +52,12 @@ def can_fetch_more(self) -> bool: class ABTreeModel(QAbstractItemModel): - def __init__(self, df: pd.DataFrame = None, parent: Optional[QWidget] = None, chunk_size: int = -1) -> None: + def __init__(self, + df: pd.DataFrame = None, + parent: Optional[QWidget] = None, + chunk_size: int = -1, + enable_sorting: bool = False + ) -> None: super().__init__(parent) self.df = df if df is not None else pd.DataFrame() self.df.index = pd.MultiIndex.from_arrays([range(len(self.df))], names=[f"index"]) @@ -60,9 +65,10 @@ def __init__(self, df: pd.DataFrame = None, parent: Optional[QWidget] = None, ch self.df_query: dict[str, str] = {"model": "index == index"} # dictionary where queries can be registered self.filtered_columns: set[int] = set() # set of column indices that have active filters, only used for the header icon self.grouped_columns: list[str] = [] # list of columns currently used for grouping + self.sorted_column: str | None = None self.sort_order = Qt.SortOrder.AscendingOrder - self.was_sorted = False + self.sorting_enabled = enable_sorting self.lazy = chunk_size > 0 self.chunk_size = chunk_size @@ -338,7 +344,6 @@ def fetchMore(self, parent: QModelIndex) -> None: # --- helper functions --- def set_dataframe(self, df: pd.DataFrame, group: list[str] = None) -> None: self.beginResetModel() - first_init = not self.was_sorted # detect first init, don't sort or filter because the view will do it anyway self.df = df self.grouped_columns = group or self.grouped_columns @@ -381,7 +386,9 @@ def ungroup(self) -> None: self.layoutChanged.emit() def sort(self, column: int | str, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: - self.was_sorted = True + if not self.sorting_enabled: + logger.warning(f"Called sort() on {self.__class__.__name__} with sorting disabled.") + return self.layoutAboutToBeChanged.emit() @@ -528,7 +535,7 @@ def apply_filter(self): self.reset_hierarchy(filtered_df) def apply_sort(self): - if self.df.empty: + if self.df.empty or not self.sorting_enabled: return logger.debug(f"Applying sorting in : {self.__class__.__name__}") From 05118c00ca2b347ab13c0785241c9570928b1a17 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 4 Dec 2025 15:32:42 +0100 Subject: [PATCH 173/267] Table optimizations --- activity_browser/ui/core/tree_model.py | 4 ++-- activity_browser/ui/delegates/new_formula.py | 2 +- activity_browser/ui/icons.py | 5 ++++- activity_browser/ui/widgets/formula_edit.py | 8 +++++--- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index a91bb536b..9f783575f 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -178,8 +178,8 @@ def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802 #--- data overrides --- def data(self, index: QModelIndex, role: int = Qt.DisplayRole): - if not index.isValid() or self.df.empty: - return None + # if not index.isValid() or self.df.empty: + # return None if role == Qt.DisplayRole: return self.displayData(index) diff --git a/activity_browser/ui/delegates/new_formula.py b/activity_browser/ui/delegates/new_formula.py index 6f43e3755..b118624bb 100644 --- a/activity_browser/ui/delegates/new_formula.py +++ b/activity_browser/ui/delegates/new_formula.py @@ -38,7 +38,7 @@ def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): from activity_browser.ui.widgets import ABFormulaEdit viewport = self.parent().findChild(QtWidgets.QWidget, "qt_scrollarea_viewport") - formula = ABFormulaEdit(viewport, scope, index.data()) + formula = ABFormulaEdit(viewport, scope, index.data(), simple=True) painter.setClipRect(option.rect) painter.translate(option.rect.topLeft()) diff --git a/activity_browser/ui/icons.py b/activity_browser/ui/icons.py index c4d7c40db..37d304a91 100644 --- a/activity_browser/ui/icons.py +++ b/activity_browser/ui/icons.py @@ -93,9 +93,12 @@ def __getattribute__(self, name): if name == 'empty': return empty_icon() elif name in icons: - return QIcon(icons[name]) + if name not in _initialized_icons: + _initialized_icons[name] = QIcon(icons[name]) + return _initialized_icons[name] else: raise AttributeError(f"QIcons has no icon '{name}'") +_initialized_icons = {} qicons = QIcons() diff --git a/activity_browser/ui/widgets/formula_edit.py b/activity_browser/ui/widgets/formula_edit.py index 0a536ff37..5762ad7fb 100644 --- a/activity_browser/ui/widgets/formula_edit.py +++ b/activity_browser/ui/widgets/formula_edit.py @@ -56,7 +56,7 @@ class Colors: class ABFormulaEdit(QWidget): - def __init__(self, parent=None, scope=None, text=None): + def __init__(self, parent=None, scope=None, text=None, simple=False): super().__init__(parent) self.scope = scope or {} self.error = False @@ -67,12 +67,16 @@ def __init__(self, parent=None, scope=None, text=None): self.scroll_offset = 0 # Scroll position for long text self.padding = 5 # Left padding for text inside the box self.dragging = False # Track if mouse is dragging + self.text = text or "" # Stores user input font = self.font() font.setFamily("JetBrains Mono") font.setPointSize(9) self.setFont(font) + if simple: + return + self.timer = QTimer(self) self.timer.timeout.connect(self.toggle_cursor) self.timer.start(500) # Blink cursor every 500ms @@ -87,8 +91,6 @@ def __init__(self, parent=None, scope=None, text=None): self.completer.setCompletionColumn(0) self.completer.activated.connect(self.insert_completion) - self.text = text or "" # Stores user input - @property def text(self): return self._text From 5faaf904c1f7b62ea19482a6cc2093d91a0fc26c Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 4 Dec 2025 15:35:11 +0100 Subject: [PATCH 174/267] Sorting in the CF model --- .../impact_category_details/impact_category_details.py | 2 +- .../app/pages/parameters/parameters_section.py | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/activity_browser/app/pages/impact_category_details/impact_category_details.py b/activity_browser/app/pages/impact_category_details/impact_category_details.py index 1cf98eccf..7c26512d4 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_details.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_details.py @@ -202,7 +202,7 @@ class CharacterizationFactorsModel(core.ABTreeModel): A model representing the characterization factors data. """ def __init__(self, page: ImpactCategoryDetailsPage): - super().__init__(parent=page) + super().__init__(parent=page, enable_sorting=True) self.page = page def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py index 8682e9f09..8dc878b15 100644 --- a/activity_browser/app/pages/parameters/parameters_section.py +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -37,6 +37,7 @@ def __init__(self, parent=None): self.model = ProjectParametersModel(parent=self) self.view = ProjectParametersView() self.view.setModel(self.model) + self.view.setUniformRowHeights(True) self.build_layout() self.connect_signals() @@ -240,15 +241,6 @@ class ProjectParametersModel(core.ABTreeModel): A model representing the data for all project parameters. """ - def __init__(self, parent=None): - """ - Initializes the ProjectParametersModel. - - Args: - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(df=pd.DataFrame(), parent=parent) - def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: """ Sets the data for the given index. From e830ba281961981a2ba686eda9cc58c6ad5cb53e Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 4 Dec 2025 16:38:21 +0100 Subject: [PATCH 175/267] Updating the search index on mds changes (Adding DBS doesn't work yet) --- activity_browser/bwutils/metadata/fields.py | 2 +- activity_browser/bwutils/metadata/loader.py | 8 +++- activity_browser/bwutils/metadata/metadata.py | 14 +++--- activity_browser/bwutils/metadata/searcher.py | 4 +- activity_browser/bwutils/metadata/updater.py | 43 ++++++++++++------- activity_browser/bwutils/searchengine/base.py | 2 +- 6 files changed, 46 insertions(+), 27 deletions(-) diff --git a/activity_browser/bwutils/metadata/fields.py b/activity_browser/bwutils/metadata/fields.py index 95cc7639f..63d12bff3 100644 --- a/activity_browser/bwutils/metadata/fields.py +++ b/activity_browser/bwutils/metadata/fields.py @@ -29,4 +29,4 @@ primary = list(primary_types.keys()) secondary = list(secondary_types.keys()) -all = primary + secondary +all_fields = primary + secondary diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 97a597c7a..468b78249 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -8,7 +8,7 @@ import pandas as pd from .metadata import MetaDataStore -from .fields import secondary_types, primary, secondary +from .fields import secondary_types, primary, secondary, search_engine_whitelist, all_fields class MDSLoader(): @@ -163,6 +163,12 @@ def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): for idx in secondary_df.index: self.mds.register_mutation(idx, "update") + if hasattr(self.mds, "searcher"): + search_engine_cols = list(set(all_fields) & set(search_engine_whitelist)) + df = self.mds.dataframe.loc[self.mds.dataframe["database"] == database, search_engine_cols] + self.mds.searcher.add_identifier(df) + + # utility functions def _fix_categories(self, df: pd.DataFrame): category_columns = [k for k, v in secondary_types.items() if v == "category"] diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index e4e1c009f..ec562c898 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -3,7 +3,7 @@ import pandas as pd -from .fields import all, all_types +from .fields import all_fields, all_types class MetaDataStore: @@ -41,7 +41,7 @@ def dataframe(self) -> pd.DataFrame: @dataframe.setter def dataframe(self, df: pd.DataFrame) -> None: # Ensure all expected columns are present, in the correct order, and with the correct types - df = df.reindex(columns=all)[all].astype(all_types) + df = df.reindex(columns=all_fields)[all_fields].astype(all_types) # No NaN values in object columns, use None instead for col, col_type in all_types.items(): @@ -119,13 +119,13 @@ def get_metadata(self, keys: list, columns: list = None) -> pd.DataFrame: def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFrame: if db_name not in self.databases: - return pd.DataFrame(columns=columns or all) - return self.dataframe.loc[[db_name], columns or all] + return pd.DataFrame(columns=columns or all_fields) + return self.dataframe.loc[[db_name], columns or all_fields] def search(self, query: str, columns: list = None) -> pd.DataFrame: if not self.searcher: logger.warning(f"Attempted to search metadata before searcher was initialized.") - return pd.DataFrame(columns=columns or all) + return pd.DataFrame(columns=columns or all_fields) params, query = get_query_parameters(query) result = self.searcher.search(query) @@ -134,14 +134,14 @@ def search(self, query: str, columns: list = None) -> pd.DataFrame: def search_database(self, query: str, database: str, columns: list = None) -> pd.DataFrame: if not self.searcher: logger.warning(f"Attempted to search metadata before searcher was initialized.") - return pd.DataFrame(columns=columns or all) + return pd.DataFrame(columns=columns or all_fields) params, query = get_query_parameters(query) result = self.searcher.fuzzy_search(query, database=database) return self._meta_from_result(params, result, columns) def _meta_from_result(self, params: dict, result: list[int], columns: list = None) -> pd.DataFrame: - df = self.dataframe.loc[self.dataframe["id"].isin(result), columns or all] + df = self.dataframe.loc[self.dataframe["id"].isin(result), columns or all_fields] df.sort_values(by="id", inplace=True, key=lambda x: x.map({id_: i for i, id_ in enumerate(result)})) extra_query = " & ".join( diff --git a/activity_browser/bwutils/metadata/searcher.py b/activity_browser/bwutils/metadata/searcher.py index ab2b235f3..0f3481849 100644 --- a/activity_browser/bwutils/metadata/searcher.py +++ b/activity_browser/bwutils/metadata/searcher.py @@ -9,7 +9,7 @@ from activity_browser.bwutils.searchengine import SearchEngine from .metadata import MetaDataStore -from .fields import all +from .fields import all_fields log = getLogger(__name__) @@ -18,7 +18,7 @@ class MDSSearcher(SearchEngine): def __init__(self, mds: MetaDataStore): self.mds = mds - super().__init__(self.mds.dataframe, "id", all) + super().__init__(self.mds.dataframe, "id", all_fields) # caching for faster operation def database_id_manager(self, database): diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index f2accccaa..1e66bc07a 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -4,7 +4,7 @@ import numpy as np from .metadata import MetaDataStore -from .fields import primary, secondary, all, all_types, search_engine_whitelist +from .fields import primary, secondary, all_types, search_engine_whitelist @@ -81,31 +81,35 @@ def modify_node(self, ds: pd.Series): self.mds.dataframe.loc[ds.key] = ds self.mds.register_mutation(ds.key, "update") - if hasattr(self.mds, "searcher"): - search_engine_cols = list( - set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns - data = pd.DataFrame([ds[search_engine_cols]]) - self.mds.searcher.change_identifier(identifier=ds["id"], data=data) + if not hasattr(self.mds, "searcher"): + return + + search_engine_cols = list(set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns + data = pd.DataFrame([ds[search_engine_cols]]) + self.mds.searcher.change_identifier(identifier=ds["id"], data=data) def add_node(self, ds: pd.Series): self._fix_categories(ds) self.mds.dataframe.loc[ds.key, :] = ds self.mds.register_mutation(ds.key, "add") - if hasattr(self.mds, "searcher"): - search_engine_cols = list( - set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns - data = pd.DataFrame([ds[search_engine_cols]]) - self.mds.searcher.add_identifier(data=data) + if not hasattr(self.mds, "searcher"): + return + + search_engine_cols = list(set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns + data = pd.DataFrame([ds[search_engine_cols]]) + self.mds.searcher.add_identifier(data=data) def delete_node(self, ds: pd.Series): self.mds.dataframe = self.mds.dataframe.drop(ds.key) self.mds.register_mutation(ds.key, "delete") - if hasattr(self.mds, "searcher"): - id = ds["id"] - self.mds.searcher.remove_identifier(identifier=id) - self.mds.searcher.reset_all_caches(ds["database"]) + if not hasattr(self.mds, "searcher"): + return + + id = ds["id"] + self.mds.searcher.remove_identifier(identifier=id) + self.mds.searcher.reset_all_caches(ds["database"]) # database methods def add_database(self, db_name: str): @@ -118,8 +122,17 @@ def delete_database(self, db_name: str): for code in self.mds.dataframe.loc[db_name].index: self.mds.register_mutation((db_name, code), "delete") + ids = self.mds.dataframe.loc[db_name, "id"].tolist() + self.mds.dataframe = self.mds.dataframe.drop(db_name, level=0) + if not hasattr(self.mds, "searcher"): + return + + for id in ids: + self.mds.searcher.remove_identifier(identifier=id) + self.mds.searcher.reset_all_caches(db_name) + # utility functions def _fix_categories(self, ds: pd.Series): for category_col in [k for k, v in all_types.items() if k in ds and v == "category"]: diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index ee34e92ea..42a8fb8dd 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -301,7 +301,7 @@ def add_identifier(self, data: pd.DataFrame) -> None: # convert df data = data.set_index(self.identifier_name, drop=False) - data = data.fillna("") + data = data.astype(object).fillna("") data = data.astype(str) # update the search index data From 947aab803c98dece7ce582ecf8654d05b1346ab9 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 5 Dec 2025 09:24:39 +0100 Subject: [PATCH 176/267] MDS model with a chunk_size --- activity_browser/app/pages/metadatastore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activity_browser/app/pages/metadatastore.py b/activity_browser/app/pages/metadatastore.py index a64a9b602..1edd96219 100644 --- a/activity_browser/app/pages/metadatastore.py +++ b/activity_browser/app/pages/metadatastore.py @@ -9,7 +9,7 @@ def __init__(self, parent=None): super().__init__(parent) self.setObjectName("MetaDataStorePage") - self.model = core.ABTreeModel(metadata.dataframe, self) + self.model = core.ABTreeModel(metadata.dataframe, self, chunk_size=50) self.view = MDSView(self) self.view.setModel(self.model) From 2d89944a35b4040f6a1eb7be9a350d434f1456eb Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 5 Dec 2025 11:12:58 +0100 Subject: [PATCH 177/267] Showing and deleting broken parameter groups --- activity_browser/app/actions/__init__.py | 1 + .../app/actions/parameter/parameter_delete.py | 66 ++++--- .../parameter/parameter_group_delete.py | 46 +++++ .../pages/parameters/parameters_section.py | 174 +++++++++++------- 4 files changed, 191 insertions(+), 96 deletions(-) create mode 100644 activity_browser/app/actions/parameter/parameter_group_delete.py diff --git a/activity_browser/app/actions/__init__.py b/activity_browser/app/actions/__init__.py index e2a484251..a105201b0 100644 --- a/activity_browser/app/actions/__init__.py +++ b/activity_browser/app/actions/__init__.py @@ -74,6 +74,7 @@ from .parameter.parameter_uncertainty_remove import ParameterUncertaintyRemove from .parameter.parameter_uncertainty_modify import ParameterUncertaintyModify from .parameter.parameter_clear_broken import ParameterClearBroken +from .parameter.parameter_group_delete import ParameterGroupDelete from .project.project_new import ProjectNew from .project.project_duplicate import ProjectDuplicate diff --git a/activity_browser/app/actions/parameter/parameter_delete.py b/activity_browser/app/actions/parameter/parameter_delete.py index d1f335297..d29ae2297 100644 --- a/activity_browser/app/actions/parameter/parameter_delete.py +++ b/activity_browser/app/actions/parameter/parameter_delete.py @@ -7,6 +7,7 @@ GroupDependency, parameters) from activity_browser.ui.icons import qicons +from activity_browser.bwutils.utils import Parameter class ParameterDelete(ABAction): @@ -19,38 +20,45 @@ class ParameterDelete(ABAction): @staticmethod @exception_dialogs - def run(parameter: Any): - if isinstance(parameter, ActivityParameter): - db = parameter.database - code = parameter.code - amount = ( - ActivityParameter.select() - .where( - (ActivityParameter.database == db) - & (ActivityParameter.code == code) + def run(parameter: Any or list[Any]): + if not isinstance(parameter, list): + parameter = [parameter] + + for parameter in parameter: + if isinstance(parameter, Parameter): + parameter = parameter.to_peewee_model() + + if isinstance(parameter, ActivityParameter): + db = parameter.database + code = parameter.code + amount = ( + ActivityParameter.select() + .where( + (ActivityParameter.database == db) + & (ActivityParameter.code == code) + ) + .count() ) - .count() - ) - if amount > 1: - parameter.delete_instance() + if amount > 1: + parameter.delete_instance() + else: + group = parameter.group + act = get_activity((db, code)) + parameters.remove_from_group(group, act) + # Also clear the group if there are no more parameters in it + + if ( + not ActivityParameter.select() + .where(ActivityParameter.group == group) + .exists() + ): + Group.delete().where(Group.name == group).execute() + GroupDependency.delete().where( + GroupDependency.group == group + ).execute() else: - group = parameter.group - act = get_activity((db, code)) - parameters.remove_from_group(group, act) - # Also clear the group if there are no more parameters in it - - if ( - not ActivityParameter.select() - .where(ActivityParameter.group == group) - .exists() - ): - Group.delete().where(Group.name == group).execute() - GroupDependency.delete().where( - GroupDependency.group == group - ).execute() - else: - parameter.delete_instance() + parameter.delete_instance() # After deleting things, recalculate and signal changes parameters.recalculate() diff --git a/activity_browser/app/actions/parameter/parameter_group_delete.py b/activity_browser/app/actions/parameter/parameter_group_delete.py new file mode 100644 index 000000000..6902d1517 --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_group_delete.py @@ -0,0 +1,46 @@ +from typing import Any + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from bw2data import get_activity +from bw2data.parameters import (ActivityParameter, Group, + GroupDependency, + parameters) +from activity_browser.ui.icons import qicons +from activity_browser.bwutils.utils import Parameter + + +class ParameterGroupDelete(ABAction): + """ + ABAction to delete an existing parameter. + """ + + icon = qicons.delete + text = "Delete parameter group..." + + @staticmethod + @exception_dialogs + def run(parameter_groups: list[str]): + for group in parameter_groups: + group_entry = Group.get(Group.name == group) + + # Delete all parameters in the group + params_in_group = ActivityParameter.select().where(ActivityParameter.group == group) + if any([ActivityParameter.is_dependent_on(p.name, p.group) for p in params_in_group]): + raise Exception(f"Cannot delete parameter group '{group}' because some parameters are dependencies for other parameters.") + + for param in params_in_group: + param.delete_instance() + + # Delete group dependencies + GroupDependency.delete().where(GroupDependency.group == group).execute() + # Delete the group itself + group_entry.delete_instance() + + + # After deleting things, recalculate and signal changes + parameters.recalculate() + + # No fire when everything is still fresh after recalculation, so need to fire manually to be sure everything is + # updated correctly. + app.signals.parameter.recalculated.emit() diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py index 8dc878b15..943760c72 100644 --- a/activity_browser/app/pages/parameters/parameters_section.py +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -2,7 +2,7 @@ from qtpy.QtCore import Qt import pandas as pd import bw2data as bd -from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter +from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, Group from activity_browser import app from activity_browser.ui import widgets, icons, delegates, core @@ -87,65 +87,68 @@ def build_df(self) -> pd.DataFrame: row = self._parameter_to_row(param) translated.append(row) - # Database parameters - for param in DatabaseParameter.select(): - row = self._parameter_to_row(param, f"{param.database}", param.database) - translated.append(row) - - # Activity parameters - for param in ActivityParameter.select(): - row = self._parameter_to_row(param, f"Group: {param.group}", param.database) - translated.append(row) - - columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_database", "_group", "_param_type"] - df = pd.DataFrame(translated, columns=columns) - df["_is_new"] = False - - # Add "New parameter..." placeholders - new_rows = [] - - # Add for project - new_rows.append({ + translated.append({ "name": "New parameter...", "_group": "project", "_param_type": "project", - "_is_new": True, + "_class": "new", }) - # Add for each database - for db_name in sorted(bd.databases.list): - if not bd.databases[db_name].get("read_only", True): - new_rows.append({ + # Database parameters + db_params = DatabaseParameter.select() + for db_name in bd.databases.list: + + for param in db_params.where(DatabaseParameter.database == db_name): + row = self._parameter_to_row(param, db_name, db_name) + translated.append(row) + + if not database_is_locked(db_name): + translated.append({ "name": "New parameter...", - "_scope": f"{db_name}", + "_scope": db_name, "_database": db_name, "_group": db_name, "_param_type": "database", - "_is_new": True, + "_class": "new", }) - # Add for each activity group - activity_params = df[df._scope.str.startswith("group: ", na=False)] - groups = activity_params._group.unique() if len(activity_params) > 0 else [] - for group_name in sorted(groups): - group_data = activity_params[activity_params._group == group_name] - db_name = group_data.iloc[0]._database if len(group_data) > 0 else None - if db_name and db_name in bd.databases and not bd.databases[db_name].get("read_only", True): - new_rows.append({ - "name": "New parameter...", - "_scope": f"group: {group_name}", - "_database": db_name, + # Activity parameters + act_params = ActivityParameter.select() + groups = Group.select() + non_act = ["project"] + bd.databases.list + + for group_name in [group.name for group in groups if group.name not in non_act]: + param = None + + for param in act_params.where(ActivityParameter.group == group_name): + row = self._parameter_to_row(param, f"Group: {group_name}", param.database) + translated.append(row) + + if param is None: + # No parameters in this group: broken group + translated.append({ + "name": "Broken parameter group", + "_scope": f"Group: {group_name}", + "_database": None, "_group": group_name, "_param_type": "activity", - "_is_new": True, + "_class": "broken", }) + continue - # Append new rows to dataframe - if new_rows: - new_df = pd.DataFrame(new_rows) - df = pd.concat([df, new_df], ignore_index=True) + if not database_is_locked(param.database): + translated.append({ + "name": "New parameter...", + "_scope": f"Group: {group_name}", + "_database": param.database, + "_group": group_name, + "_param_type": "activity", + "_class": "new", + }) - return df.sort_values(by="_param_type", key=lambda c: c.map({"project": 0, "database": 1, "activity": 2})) + columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_database", "_group", "_param_type", "_class"] + df = pd.DataFrame(translated, columns=columns) + return df def _parameter_to_row(self, param, scope_label: str = None, database: str = None) -> dict: """ @@ -188,6 +191,7 @@ def _parameter_to_row(self, param, scope_label: str = None, database: str = None "_scope": scope_label, "_database": database, "_group": group, + "_class": "instantiated", } return row @@ -212,29 +216,55 @@ class ContextMenu(widgets.ABMenu): """ A context menu for the ProjectParametersView. """ + menuSetup = [ + lambda m, p: m.add(app.actions.ParameterDelete, p.selected_parameters(), + text="Delete parameter(s)", + enable=(all([p.deletable for p in p.selected_parameters()]) + and len(p.selected_parameters()) > 0) + and all([not database_is_locked(p.database) + for p in p.selected_parameters() + if p.param_type != "project" + ]) + ), + lambda m, p: m.add(app.actions.ParameterGroupDelete, p.selected_groups(), + text="Delete parameter group(s)", + enable=(len(p.selected_groups()) > 0 + and all([not database_is_locked(p.database) + for p in p.selected_parameters() + if p.param_type != "project" + ]) + ) + ), + ] + + def selected_parameters(self): + """ + Returns a list of selected parameters in the view. - def __init__(self, pos, view: "ProjectParametersView"): - """ - Initializes the ContextMenu. - - Args: - pos: The position of the context menu. - view (ProjectParametersView): The view displaying the parameters. - """ - super().__init__(view) - - index = view.indexAt(pos) - if index.isValid() and not view.model().isBranchNode(index): - row = view.model().row(index) - if row is not None and not row.get("_is_new"): - parameter = row.get("_parameter") - if parameter: - param = refresh_parameter(parameter).to_peewee_model() - self.del_param_action = app.actions.ParameterDelete().get_QAction(param) - if not param.is_deletable() or param.name == "dummy_parameter": - self.del_param_action.setEnabled(False) - self.addAction(self.del_param_action) + Returns: + list: A list of selected Parameter objects. + """ + selected = [] + for index in self.selectedIndexes(): + parameter = self.model().get(index, "_parameter") + if parameter is not None and not pd.isna(parameter) and parameter not in selected: + selected.append(parameter) + + return selected + def selected_groups(self): + """ + Returns a list of selected parameter groups in the view. + + Returns: + list: A list of selected parameter group names. + """ + selected = set() + for index in self.selectedIndexes(): + group = self.model().get(index, "_group") + group and selected.add(group) + + return list(selected) class ProjectParametersModel(core.ABTreeModel): """ @@ -263,7 +293,7 @@ def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole. return False # Handle "New parameter..." rows - if row.get("_is_new"): + if row.get("_class") == "new": if column_name != "name" or value == "": return False @@ -320,11 +350,17 @@ def fontData(self, index: QtCore.QModelIndex) -> any: Returns: QtGui.QFont: The font data for the index. """ - if self.get(index, "_is_new"): + param_class = self.get(index, "_class") + if param_class == "new": font = QtGui.QFont() font.setWeight(QtGui.QFont.Weight.ExtraLight) return font + if param_class == "broken": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.Bold) + return font + return None def indexEditable(self, index: QtCore.QModelIndex) -> bool: @@ -344,6 +380,10 @@ def indexEditable(self, index: QtCore.QModelIndex) -> bool: if not pd.isna(database) and database_is_locked(database): return False + # Prevent editing broken parameters + if self.get(index, "_class") == "broken": + return False + # Allow editing for specific columns if column_name in ["formula", "uncertainty", "name", "comment"]: return True From cc91571723599ac04748a52adb916e9c76cc49ed Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 5 Dec 2025 11:23:34 +0100 Subject: [PATCH 178/267] MDS searcher signal fix --- activity_browser/app/panes/database_products.py | 1 + 1 file changed, 1 insertion(+) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index b9c398d0e..7e56e46f5 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -124,6 +124,7 @@ def connect_signals(self): app.signals.database.deleted.connect(self.on_database_deleted) self.view_toggle.checkStateChanged.connect(self.on_mode_switch) + self.search_bar.textChangedDebounce.connect(self.sync) def on_metadata_changed(self, added, updated, deleted): # Check if primary data has finished loading From 772b6d9f8f92381ec510e1b6c9d18583951a7a43 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 5 Dec 2025 11:59:00 +0100 Subject: [PATCH 179/267] Excel linking using MDS --- activity_browser/bwutils/importers.py | 21 ++++------------- activity_browser/bwutils/strategies.py | 31 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/activity_browser/bwutils/importers.py b/activity_browser/bwutils/importers.py index a5973726e..69dbb49ea 100644 --- a/activity_browser/bwutils/importers.py +++ b/activity_browser/bwutils/importers.py @@ -25,7 +25,7 @@ from .strategies import (alter_database_name, csv_rewrite_product_key, hash_parameter_group, link_exchanges_without_db, relink_exchanges_bw2package, relink_exchanges_with_db, - rename_db_bw2package, parse_JSON_fields) + rename_db_bw2package, parse_JSON_fields, metadatastore_link, alter_exchange_database_name) class ABExcelImporter(ExcelImporter): @@ -163,24 +163,11 @@ def apply_db_name(self, db_name: str): def apply_linking(self, relink: dict): self.apply_strategies([ - functools.partial( - link_iterable_by_fields, - other=bd.Database(bd.config.biosphere), - kind="biosphere", - ), - link_technosphere_by_activity_hash, + link_technosphere_by_activity_hash, # internal linking + functools.partial(alter_exchange_database_name, linking_dict=relink), # change db names + metadatastore_link, # link using metadatastore ]) - for db, new_db in relink.items(): - if db == "(name missing)": - self.apply_strategy( - functools.partial(link_exchanges_without_db, db=new_db) - ) - else: - self.apply_strategy( - functools.partial(relink_exchanges_with_db, old=db, new=new_db) - ) - def apply_strategies(self, strategies=None, verbose=False): strategies = strategies or self.strategies diff --git a/activity_browser/bwutils/strategies.py b/activity_browser/bwutils/strategies.py index db3fc6b1b..f23967fb7 100644 --- a/activity_browser/bwutils/strategies.py +++ b/activity_browser/bwutils/strategies.py @@ -29,6 +29,26 @@ "location", ) +def metadatastore_link(data: list) -> list: + from .metadata import MetaDataStore + mds = MetaDataStore() + + for act in data: + for exc in act.get("exchanges", []): + match = mds.match( + name=exc.get("name"), + database=exc.get("database"), + categories=exc.get("categories"), + unit=exc.get("unit"), + product=exc.get("reference product"), + location=exc.get("location"), + ) + if len(match) == 1: + exc["input"] = match.index[0] + + return data + + def relink_exchanges_dbs(data: Collection, relink: dict) -> Collection: """Use this to relink exchanges during an actual import.""" @@ -261,6 +281,17 @@ def alter_database_name(data: list, old: str, new: str) -> list: ds["processor"] = (new, ds["processor"][1]) return data +def alter_exchange_database_name(data: list, linking_dict: dict[str, str]) -> list: + """For ABExcelImporter, go through data and replace all instances + of the `old` database name with `new` in exchanges only. + """ + for ds in data: + for exc in ds.get("exchanges", []): + # Note: this will only alter database if the field exists in the exchange. + if exc.get("database") in linking_dict: + exc["database"] = linking_dict[exc["database"]] + return data + def hash_parameter_group(data: list) -> list: """For ABExcelImporter, go through `data` and change all the activity parameter From 3404f74670e2121179fea6fa141b31652acda7a7 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 5 Dec 2025 14:44:33 +0100 Subject: [PATCH 180/267] Enable database relinking --- .../app/actions/database/database_relink.py | 83 ++++++++++++++----- activity_browser/app/panes/databases.py | 1 + 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/activity_browser/app/actions/database/database_relink.py b/activity_browser/app/actions/database/database_relink.py index cd3c968c3..ba6325b92 100644 --- a/activity_browser/app/actions/database/database_relink.py +++ b/activity_browser/app/actions/database/database_relink.py @@ -1,9 +1,10 @@ from qtpy import QtCore, QtWidgets -from activity_browser.app import application +import bw2data as bd +from bw2data.backends import ExchangeDataset, sqlite3_lci_db + +from activity_browser.app import application, metadata from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils.strategies import relink_exchanges_existing_db -from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons @@ -23,8 +24,10 @@ def run(db_name: str): # get brightway database object db = bd.Database(db_name) + depends = ExchangeDataset.select(ExchangeDataset.input_database).where(ExchangeDataset.output_database == db_name) + depends = set([d.input_database for d in depends if d.input_database != db_name]) + # find the dependencies of the database and construct a list of suitable candidates - depends = db.find_dependents() options = [(depend, list(bd.databases)) for depend in depends] # construct a dialog in which the user chan choose which depending database to connect to which candidate @@ -36,25 +39,65 @@ def run(db_name: str): if dialog.exec_() != DatabaseLinkingDialog.Accepted: return - # else, start the relinking - QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) - relinking_results = dict() + linking_dict = {k: v for k, v in dialog.links.items() if k != v} - # relink using relink_exchanges_existing_db strategy - for old, new in dialog.relink.items(): - other = bd.Database(new) - failed, succeeded, examples = relink_exchanges_existing_db(db, old, other) - relinking_results[f"{old} --> {other.name}"] = (failed, succeeded) - - QtWidgets.QApplication.restoreOverrideCursor() + if not linking_dict: + return - # if any failed, present user with results dialog - if failed > 0: - relinking_dialog = DatabaseLinkingResultsDialog.present_relinking_results( - application.main_window, relinking_results, examples + relink_keys = DatabaseRelink.get_input_keys(db_name, list(linking_dict.keys())) + datasets = metadata.get_metadata(relink_keys, ["name", "product", "unit", "categories", "location"]) + + relink_key_map = {} + for ds in datasets.itertuples(): + key = ds.Index + database = linking_dict.get(key[0]) + match = metadata.match( + name=ds.name, + product=ds.product, + unit=ds.unit, + categories=ds.categories, + location=ds.location, + database=database, ) - relinking_dialog.exec_() - relinking_dialog.open_activity() + + if not len(match) == 1: + raise Exception(f"Could not uniquely relink exchange from {key} in database {database}") + + relink_key_map[key] = match.index[0] + + DatabaseRelink.set_input_keys(db_name, relink_key_map) + + QtWidgets.QMessageBox.information( + application.main_window, + "Database relinked", + f"Successfully relinked database '{db_name}'." + ) + + @staticmethod + def get_input_keys(output_db: str, db_list: list[str]) -> list[tuple[str, str]]: + return list( + ( + ExchangeDataset + .select(ExchangeDataset.input_database, ExchangeDataset.input_code) + .where( + (ExchangeDataset.output_database == output_db) & + (ExchangeDataset.input_database << db_list) + ) + ).tuples() + ) + + @staticmethod + def set_input_keys(output_db: str, key_map: dict[tuple[str, str], tuple[str, str]]) -> None: + with sqlite3_lci_db.db.atomic(): + for old_key, new_key in key_map.items(): + ExchangeDataset.update( + input_database=new_key[0], + input_code=new_key[1] + ).where( + (ExchangeDataset.output_database == output_db) & + (ExchangeDataset.input_database == old_key[0]) & + (ExchangeDataset.input_code == old_key[1]) + ).execute() class DatabaseLinkingDialog(QtWidgets.QDialog): diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py index a067ec667..7558c9a2a 100644 --- a/activity_browser/app/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -135,6 +135,7 @@ class ContextMenu(widgets.ABMenu): ), lambda m, p: m.add(app.actions.DatabaseDuplicate, p.selected_databases[0] if p.selected_databases else None, enable=len(p.selected_databases) == 1), + lambda m, p: m.add(app.actions.DatabaseRelink, p.selected_databases[0] if p.selected_databases else None), lambda m, p: m.add(app.actions.DatabaseProcess, p.selected_databases[0] if p.selected_databases else None, enable=len(p.selected_databases) == 1), lambda m: m.addSeparator(), From 29a5b63074e42b9e124ba667f5943aebdaeed0bf Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 5 Dec 2025 15:43:10 +0100 Subject: [PATCH 181/267] Hide allocation factor of non-production exchanges --- .../app/pages/activity_details/exchanges_tab.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index 4276557dd..7531bc7fe 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -145,8 +145,7 @@ def build_df(self, exchanges) -> pd.DataFrame: "properties", "processor", "categories", "type"] # Create a DataFrame from the exchanges - exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "formula", "comment"]) - exc_df["type"] = [x["type"] for x in exchanges] + exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "formula", "comment", "type"]) exc_df["uncertainty"] = [x.uncertainty for x in exchanges] act_df = app.metadata.get_metadata(exc_df["input"].unique(), cols).rename(columns={"type": "_producer_type"}) @@ -157,6 +156,9 @@ def build_df(self, exchanges) -> pd.DataFrame: right_on="key" ).drop(columns=["key"]) + # Set allocation_factor to NA for non-production exchanges + df.loc[df["type"] != "production", "allocation_factor"] = pd.NA + # Handle properties data if available if not act_df.properties.isna().all(): props_df = act_df[act_df.properties.notna()] @@ -186,7 +188,6 @@ def build_df(self, exchanges) -> pd.DataFrame: # Define the order of columns for the final DataFrame cols = ["amount", "unit", "product", "producer", "location", "categories", "database"] - cols += ["substitute_name", "substitution_factor"] if "substitute_name" in df.columns else [] cols += ["allocation_factor"] if not database_is_legacy(self.activity.get("database")) else [] cols += [col for col in df.columns if col.startswith("property")] cols += ["formula", "comment", "uncertainty"] From 5f4caff59bfcfdf2188d92ca67e59ad90c1a3050 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 5 Dec 2025 16:16:44 +0100 Subject: [PATCH 182/267] Node select to ctrl+shift+F --- activity_browser/app/actions/node_select_open.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activity_browser/app/actions/node_select_open.py b/activity_browser/app/actions/node_select_open.py index 1dcae5465..aa3ee6ae7 100644 --- a/activity_browser/app/actions/node_select_open.py +++ b/activity_browser/app/actions/node_select_open.py @@ -13,7 +13,7 @@ class NodeSelectOpen(ABAction): text = "Open activity / activities" @staticmethod - @global_shortcut("Ctrl+Shift+N") + @global_shortcut("Ctrl+Shift+F") @exception_dialogs def run(): from activity_browser.app import dialogs From 8fde7a213aa905948541fcc88ffa3e4170429793 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 5 Dec 2025 16:50:34 +0100 Subject: [PATCH 183/267] Better search dialog and button --- .../app/actions/node_select_open.py | 4 ++-- .../app/dialogs/node_select_dialog.py | 22 +++++-------------- activity_browser/app/menu_bar.py | 14 +++++++++--- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/activity_browser/app/actions/node_select_open.py b/activity_browser/app/actions/node_select_open.py index aa3ee6ae7..5050f7fe6 100644 --- a/activity_browser/app/actions/node_select_open.py +++ b/activity_browser/app/actions/node_select_open.py @@ -9,8 +9,8 @@ class NodeSelectOpen(ABAction): - icon = qicons.right - text = "Open activity / activities" + icon = qicons.search + text = "Search project" @staticmethod @global_shortcut("Ctrl+Shift+F") diff --git a/activity_browser/app/dialogs/node_select_dialog.py b/activity_browser/app/dialogs/node_select_dialog.py index fc00dd423..14d935c5d 100644 --- a/activity_browser/app/dialogs/node_select_dialog.py +++ b/activity_browser/app/dialogs/node_select_dialog.py @@ -14,10 +14,9 @@ def __init__(self, parent=None, drag_enabled=False): super().__init__(parent) self.setWindowFlags( - QtCore.Qt.WindowType.Sheet | - QtCore.Qt.WindowType.CustomizeWindowHint + QtCore.Qt.WindowType.Popup | + QtCore.Qt.WindowType.FramelessWindowHint ) - self.setModal(True) self.setFixedWidth(400) self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.Maximum) @@ -35,25 +34,16 @@ def __init__(self, parent=None, drag_enabled=False): self.tree_view.setDragEnabled(drag_enabled) layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(5, 0, 5, 0) + layout.setContentsMargins(5, 5, 5, 0) layout.addWidget(self.edit) layout.addWidget(self.tree_view) self.setLayout(layout) self.setFixedHeight(self.sizeHint().height()) - # def showEvent(self, event): - # """Position the dialog 200px higher than default centered position""" - # super().showEvent(event) - # if self.parent(): - # parent_rect = self.parent().geometry() - # dialog_rect = self.geometry() - # - # # Center horizontally, but move up 200px from center vertically - # x = parent_rect.x() + (parent_rect.width() - dialog_rect.width()) // 2 - # y = parent_rect.y() + (parent_rect.height() - dialog_rect.height()) // 2 - 200 - # - # self.move(x, y) + def showEvent(self, event): + super().showEvent(event) + self.edit.setFocus() def on_search(self, text: str): if not text.strip(): diff --git a/activity_browser/app/menu_bar.py b/activity_browser/app/menu_bar.py index cca441e39..b2ebc6a31 100644 --- a/activity_browser/app/menu_bar.py +++ b/activity_browser/app/menu_bar.py @@ -1,9 +1,10 @@ from importlib.metadata import version import bw2data as bd +from PySide6 import QtCore from qtpy import QtGui, QtWidgets -from qtpy.QtCore import QSize, QUrl +from qtpy.QtCore import QSize, QUrl, Qt from activity_browser import app, app from activity_browser.bwutils.commontasks import get_templates @@ -21,15 +22,22 @@ def __init__(self, window): self.project_menu = ProjectMenu(self) self.view_menu = ViewMenu(self) self.calculate_menu = CalculateMenu(self) - # self.tools_menu = ToolsMenu(self) self.help_menu = HelpMenu(self) self.addMenu(self.project_menu) self.addMenu(self.view_menu) self.addMenu(self.calculate_menu) - # self.addMenu(self.tools_menu) self.addMenu(self.help_menu) + self.search_button = QtWidgets.QPushButton(self) + self.search_button.setFlat(True) + self.search_button.setIcon(qicons.search) + self.search_button.setIconSize(QtCore.QSize(13, 13)) + self.search_button.setToolTip("Search project (Ctrl+Shift+F)") + self.search_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.search_button.clicked.connect(app.actions.NodeSelectOpen.run) + self.setCornerWidget(self.search_button, Qt.Corner.TopRightCorner) + class ProjectMenu(QtWidgets.QMenu): """ From 8c970d337ea5267e9edcf439dcdf7695572143d5 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sat, 6 Dec 2025 15:02:09 +0100 Subject: [PATCH 184/267] logging syncs --- .../app/dialogs/import_preview_dialog/edge_tab.py | 4 ++++ .../app/dialogs/import_preview_dialog/node_tab.py | 4 ++++ activity_browser/app/main_window.py | 1 + activity_browser/app/menu_bar.py | 8 +++++--- .../app/pages/activity_details/activity_details.py | 4 ++-- .../app/pages/activity_details/activity_header.py | 7 +++++-- .../app/pages/activity_details/consumers_tab.py | 5 ++++- activity_browser/app/pages/activity_details/data_tab.py | 3 +++ .../app/pages/activity_details/description_tab.py | 3 +++ .../app/pages/activity_details/exchanges_tab.py | 2 ++ activity_browser/app/pages/activity_details/graph_tab.py | 2 ++ .../app/pages/activity_details/parameters_tab.py | 4 +++- .../app/pages/calculation_setup/calculation_setup.py | 5 ++++- .../pages/calculation_setup/functional_unit_section.py | 3 +++ .../pages/calculation_setup/impact_category_section.py | 6 ++++-- .../impact_category_details/impact_category_details.py | 3 +++ .../impact_category_details/impact_category_header.py | 8 ++++++++ activity_browser/app/pages/metadatastore.py | 2 ++ .../pages/parameters/parameterized_exchanges_section.py | 5 +++++ .../app/pages/parameters/parameters_section.py | 5 +++++ activity_browser/app/pages/settings/project_manager.py | 3 ++- activity_browser/app/panes/calculation_setups.py | 4 +++- activity_browser/app/panes/database_products.py | 2 ++ activity_browser/app/panes/databases.py | 6 +++++- activity_browser/app/panes/impact_categories.py | 6 ++++-- 25 files changed, 88 insertions(+), 17 deletions(-) diff --git a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py index 5891e7a6f..3fd759b7b 100644 --- a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py +++ b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py @@ -2,6 +2,8 @@ from qtpy import QtWidgets, QtCore, QtGui from qtpy.QtCore import Qt +from loguru import logger + import pandas as pd from bw2io.importers.base_lci import LCIImporter @@ -49,6 +51,8 @@ def __init__(self, importer: LCIImporter, parent=None): def sync(self): """Synchronize the view based on simple/detailed mode.""" + logger.debug(f"Syncing {self.__class__.__name__}") + self.edge_view.header().setHidden(self.simple) self.edge_view.viewport().setBackgroundRole( QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) diff --git a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py index efaa2808f..2d2199a8b 100644 --- a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py +++ b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py @@ -2,6 +2,8 @@ from qtpy import QtWidgets, QtCore, QtGui from qtpy.QtCore import Qt +from loguru import logger + import pandas as pd from bw2io.importers.base_lci import LCIImporter @@ -45,6 +47,8 @@ def __init__(self, importer: LCIImporter, parent=None): def sync(self): """Synchronize the view based on simple/detailed mode.""" + logger.debug(f"Syncing {self.__class__.__name__}") + self.node_view.header().setHidden(self.simple) self.node_view.viewport().setBackgroundRole( QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) diff --git a/activity_browser/app/main_window.py b/activity_browser/app/main_window.py index 45fb30c93..0940fc916 100644 --- a/activity_browser/app/main_window.py +++ b/activity_browser/app/main_window.py @@ -52,6 +52,7 @@ def __init__(self, parent=None): self.connect_signals() def sync(self): + logger.debug(f"Syncing {self.__class__.__name__}") self.sync_panes() self.sync_pages() diff --git a/activity_browser/app/menu_bar.py b/activity_browser/app/menu_bar.py index b2ebc6a31..587f3ad4f 100644 --- a/activity_browser/app/menu_bar.py +++ b/activity_browser/app/menu_bar.py @@ -1,12 +1,12 @@ from importlib.metadata import version +from loguru import logger import bw2data as bd -from PySide6 import QtCore -from qtpy import QtGui, QtWidgets +from qtpy import QtGui, QtWidgets, QtCore from qtpy.QtCore import QSize, QUrl, Qt -from activity_browser import app, app +from activity_browser import app from activity_browser.bwutils.commontasks import get_templates from ..ui.icons import qicons @@ -167,6 +167,8 @@ def __init__(self, parent=None) -> None: app.signals.meta.calculation_setups_changed.connect(self.sync) def sync(self): + logger.debug(f"Syncing {self.__class__.__name__}") + self.cs_actions.clear() for cs in bd.calculation_setups: action = app.actions.CSOpen.get_QAction(cs) diff --git a/activity_browser/app/pages/activity_details/activity_details.py b/activity_browser/app/pages/activity_details/activity_details.py index f18c0126c..b4a19bda6 100644 --- a/activity_browser/app/pages/activity_details/activity_details.py +++ b/activity_browser/app/pages/activity_details/activity_details.py @@ -17,8 +17,6 @@ from .consumers_tab import ConsumersTab - - class ActivityDetailsPage(QtWidgets.QWidget): """ A widget that displays detailed information about a specific activity. @@ -150,6 +148,8 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ + logger.debug(f"Syncing {self.__class__.__name__}") + self.activity = refresh_node_or_none(self.activity) if self.activity is None: diff --git a/activity_browser/app/pages/activity_details/activity_header.py b/activity_browser/app/pages/activity_details/activity_header.py index dcb259beb..e506607cf 100644 --- a/activity_browser/app/pages/activity_details/activity_header.py +++ b/activity_browser/app/pages/activity_details/activity_header.py @@ -1,9 +1,10 @@ -from qtpy import QtWidgets, QtCore, QtGui +from qtpy import QtWidgets, QtCore +from loguru import logger import bw2data as bd import bw_functional as bf -from activity_browser import app, app +from activity_browser import app from activity_browser.bwutils.commontasks import refresh_node, database_is_locked from activity_browser.ui import widgets @@ -38,6 +39,8 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ + logger.debug(f"Syncing {self.__class__.__name__}") + self.activity = refresh_node(self.activity) self.clear_layout() diff --git a/activity_browser/app/pages/activity_details/consumers_tab.py b/activity_browser/app/pages/activity_details/consumers_tab.py index 203e022c1..389a028bb 100644 --- a/activity_browser/app/pages/activity_details/consumers_tab.py +++ b/activity_browser/app/pages/activity_details/consumers_tab.py @@ -1,10 +1,11 @@ from qtpy import QtWidgets +from loguru import logger import pandas as pd import bw2data as bd import bw_functional as bf -from activity_browser import app, app +from activity_browser import app from activity_browser.bwutils.commontasks import refresh_node from activity_browser.ui import widgets, icons, core @@ -51,6 +52,8 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ + logger.debug(f"Syncing {self.__class__.__name__}") + self.activity = refresh_node(self.activity) exchanges = [] if isinstance(self.activity, bf.Process): diff --git a/activity_browser/app/pages/activity_details/data_tab.py b/activity_browser/app/pages/activity_details/data_tab.py index b276ccc54..c8f7d1924 100644 --- a/activity_browser/app/pages/activity_details/data_tab.py +++ b/activity_browser/app/pages/activity_details/data_tab.py @@ -1,4 +1,5 @@ from qtpy import QtWidgets, QtCore +from loguru import logger import pandas as pd import bw2data as bd @@ -55,6 +56,8 @@ def sync(self) -> None: """ Synchronizes the widget with the current state of the activity. """ + logger.debug(f"Syncing {self.__class__.__name__}") + self.activity = refresh_node(self.activity) df = self.build_df() df.reset_index(drop=True, inplace=True) diff --git a/activity_browser/app/pages/activity_details/description_tab.py b/activity_browser/app/pages/activity_details/description_tab.py index c4b6c6533..71f97ebde 100644 --- a/activity_browser/app/pages/activity_details/description_tab.py +++ b/activity_browser/app/pages/activity_details/description_tab.py @@ -1,4 +1,5 @@ from qtpy import QtWidgets, QtGui +from loguru import logger import bw2data as bd @@ -29,6 +30,8 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ + logger.debug(f"Syncing {self.__class__.__name__}") + self.activity = refresh_node(self.activity) self.setText(self.activity.get("comment", "")) self.moveCursor(QtGui.QTextCursor.MoveOperation.End) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index 7531bc7fe..973ca1210 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -97,6 +97,8 @@ def sync(self) -> None: """ Synchronizes the widget with the current state of the activity. """ + logger.debug(f"Syncing {self.__class__.__name__}") + # Refresh the activity node self.activity = refresh_node(self.activity) diff --git a/activity_browser/app/pages/activity_details/graph_tab.py b/activity_browser/app/pages/activity_details/graph_tab.py index ebb28d61c..796005ce2 100644 --- a/activity_browser/app/pages/activity_details/graph_tab.py +++ b/activity_browser/app/pages/activity_details/graph_tab.py @@ -73,6 +73,8 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ + logger.debug(f"Syncing {self.__class__.__name__}") + self.activity = refresh_node(self.activity) json = self.build_json() self.bridge.update_graph.emit(json) diff --git a/activity_browser/app/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py index 8f991710c..c4596f957 100644 --- a/activity_browser/app/pages/activity_details/parameters_tab.py +++ b/activity_browser/app/pages/activity_details/parameters_tab.py @@ -1,5 +1,6 @@ from qtpy import QtWidgets, QtCore from qtpy.QtCore import Qt +from loguru import logger import pandas as pd import bw2data as bd @@ -7,7 +8,6 @@ from activity_browser import app from activity_browser.ui import widgets, icons, delegates, core from activity_browser.bwutils.commontasks import refresh_node, refresh_parameter, parameters_in_scope, database_is_locked, node_group -from activity_browser.bwutils.utils import Parameter class ParametersTab(QtWidgets.QWidget): @@ -59,6 +59,8 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ + logger.debug(f"Syncing {self.__class__.__name__}") + self.activity = refresh_node(self.activity) df = self.build_df() df.reset_index(drop=True, inplace=True) diff --git a/activity_browser/app/pages/calculation_setup/calculation_setup.py b/activity_browser/app/pages/calculation_setup/calculation_setup.py index c2a9f0775..beb0ffe6f 100644 --- a/activity_browser/app/pages/calculation_setup/calculation_setup.py +++ b/activity_browser/app/pages/calculation_setup/calculation_setup.py @@ -1,6 +1,7 @@ from qtpy import QtWidgets +from loguru import logger -from activity_browser import app, app +from activity_browser import app from activity_browser.ui import widgets, icons from .scenario_section import ScenarioSection @@ -70,6 +71,8 @@ def connect_signals(self): self.run_button.released.connect(self.run_calculation) def sync(self) -> None: + logger.debug(f"Syncing {self.__class__.__name__}") + self.functional_unit_section.sync() self.impact_category_section.sync() diff --git a/activity_browser/app/pages/calculation_setup/functional_unit_section.py b/activity_browser/app/pages/calculation_setup/functional_unit_section.py index d688d4243..b6d980a58 100644 --- a/activity_browser/app/pages/calculation_setup/functional_unit_section.py +++ b/activity_browser/app/pages/calculation_setup/functional_unit_section.py @@ -1,5 +1,6 @@ from qtpy import QtWidgets, QtCore from qtpy.QtCore import Qt +from loguru import logger import bw2data as bd import pandas as pd @@ -28,6 +29,8 @@ def build_layout(self): self.setLayout(layout) def sync(self): + logger.debug(f"Syncing {self.__class__.__name__}") + try: self.calculation_setup = bd.calculation_setups[self.calculation_setup_name] df = self.build_df() diff --git a/activity_browser/app/pages/calculation_setup/impact_category_section.py b/activity_browser/app/pages/calculation_setup/impact_category_section.py index a4fb1fee5..c54906a91 100644 --- a/activity_browser/app/pages/calculation_setup/impact_category_section.py +++ b/activity_browser/app/pages/calculation_setup/impact_category_section.py @@ -1,5 +1,5 @@ -from qtpy import QtWidgets, QtCore -from qtpy.QtCore import Qt +from qtpy import QtWidgets +from loguru import logger import bw2data as bd import pandas as pd @@ -27,6 +27,8 @@ def build_layout(self): self.setLayout(layout) def sync(self): + logger.debug(f"Syncing {self.__class__.__name__}") + try: self.calculation_setup = bd.calculation_setups[self.calculation_setup_name] df = self.build_df() diff --git a/activity_browser/app/pages/impact_category_details/impact_category_details.py b/activity_browser/app/pages/impact_category_details/impact_category_details.py index 7c26512d4..74ccbf9e7 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_details.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_details.py @@ -1,5 +1,6 @@ from qtpy import QtWidgets, QtGui, QtCore from qtpy.QtCore import Qt +from loguru import logger import bw2data as bd import pandas as pd @@ -46,6 +47,8 @@ def on_method_deleted(self, method): self.deleteLater() def sync(self): + logger.debug(f"Syncing {self.__class__.__name__}") + if self.name not in bd.methods: self.deleteLater() return diff --git a/activity_browser/app/pages/impact_category_details/impact_category_header.py b/activity_browser/app/pages/impact_category_details/impact_category_header.py index 2b26afbd9..12684709a 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_header.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_header.py @@ -1,4 +1,6 @@ from qtpy import QtWidgets, QtCore +from loguru import logger + from activity_browser import app from activity_browser.ui import widgets @@ -42,6 +44,8 @@ def sync(self): Synchronizes the widget with the current state of the impact category. Switches between editable and view-only headers based on edit mode. """ + logger.debug(f"Syncing {self.__class__.__name__}") + self.impact_category = self.parent().impact_category # Update both headers with current data @@ -101,6 +105,8 @@ def sync(self): """ Updates the displayed information from the current impact category. """ + logger.debug(f"Syncing {self.__class__.__name__}") + impact_category = self.parent().impact_category self.name_label.setText(" | ".join(impact_category.name)) self.unit_label.setText(impact_category.metadata.get("unit", "Undefined")) @@ -145,6 +151,8 @@ def sync(self): """ Updates the displayed information from the current impact category. """ + logger.debug(f"Syncing {self.__class__.__name__}") + impact_category = self.parent().impact_category self.name_label.setText(f"{' | '.join(impact_category.name)}") self.unit_edit.setText(impact_category.metadata.get("unit", "Undefined")) diff --git a/activity_browser/app/pages/metadatastore.py b/activity_browser/app/pages/metadatastore.py index 1edd96219..5f0d5d751 100644 --- a/activity_browser/app/pages/metadatastore.py +++ b/activity_browser/app/pages/metadatastore.py @@ -1,4 +1,5 @@ from qtpy import QtWidgets +from loguru import logger from activity_browser.ui import widgets, delegates, core from activity_browser.app import metadata, signals @@ -20,6 +21,7 @@ def connect_signals(self): signals.metadata.synced.connect(self.sync) def sync(self): + logger.debug(f"Syncing {self.__class__.__name__}") self.model.set_dataframe(metadata.dataframe) def build_layout(self): diff --git a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py index 5a20f5663..6ee83d172 100644 --- a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py +++ b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py @@ -1,5 +1,8 @@ +from loguru import logger + from qtpy import QtWidgets, QtCore from qtpy.QtCore import Qt + import pandas as pd import bw2data as bd from bw2data.parameters import ParameterizedExchange @@ -61,6 +64,8 @@ def sync(self): """ Synchronizes the widget with the current state of parameterized exchanges. """ + logger.debug(f"Syncing {self.__class__.__name__}") + df = self.build_exchanges_df() self.model.set_dataframe(df) diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py index 943760c72..28aa6e2a1 100644 --- a/activity_browser/app/pages/parameters/parameters_section.py +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -1,5 +1,8 @@ +from loguru import logger + from qtpy import QtWidgets, QtCore, QtGui from qtpy.QtCore import Qt + import pandas as pd import bw2data as bd from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, Group @@ -65,6 +68,8 @@ def sync(self): """ Synchronizes the widget with the current state of parameters. """ + logger.debug(f"Syncing {self.__class__.__name__}") + df = self.build_df() self.model.set_dataframe(df, group=["_param_type", "_scope"]) self.view.expandAll() diff --git a/activity_browser/app/pages/settings/project_manager.py b/activity_browser/app/pages/settings/project_manager.py index 56bcd7cd6..5ae30413a 100644 --- a/activity_browser/app/pages/settings/project_manager.py +++ b/activity_browser/app/pages/settings/project_manager.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from loguru import logger import pandas as pd @@ -51,6 +50,8 @@ def connect_signals(self): def sync(self): """Sync project and template data.""" + logger.debug(f"Syncing {self.__class__.__name__}") + df = self.build_project_df() self.project_model.set_dataframe(df) self.project_view.resizeColumnToContents(1) diff --git a/activity_browser/app/panes/calculation_setups.py b/activity_browser/app/panes/calculation_setups.py index 61cd5861e..092c2fffc 100644 --- a/activity_browser/app/panes/calculation_setups.py +++ b/activity_browser/app/panes/calculation_setups.py @@ -1,9 +1,10 @@ from qtpy import QtWidgets, QtGui +from loguru import logger import bw2data as bd import pandas as pd -from activity_browser import app, app +from activity_browser import app from activity_browser.ui import widgets, delegates, core @@ -52,6 +53,7 @@ def sync(self): """ Synchronizes the model with the current state of the calculation setups. """ + logger.debug(f"Syncing {self.__class__.__name__}") df = self.build_df() self.model.set_dataframe(df) self.view.resizeColumnToContents(0) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 7e56e46f5..e72f1d494 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -149,6 +149,8 @@ def sync(self): """ Synchronizes the widget with the current state of the database. """ + logger.debug(f"Syncing {self.__class__.__name__}") + t = time() df = self.build_df() diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py index 7558c9a2a..06ad2633f 100644 --- a/activity_browser/app/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -1,4 +1,5 @@ import datetime +from loguru import logger from qtpy import QtWidgets, QtGui, QtCore from qtpy.QtCore import Qt @@ -6,7 +7,7 @@ import bw2data as bd import pandas as pd -from activity_browser import app, app +from activity_browser import app from activity_browser.bwutils.commontasks import count_database_records from activity_browser.ui import widgets, icons, delegates, core from activity_browser.app.menu_bar import ImportDatabaseMenu @@ -59,10 +60,13 @@ def build_layout(self): layout.setContentsMargins(5, 0, 5, 5) self.setLayout(layout) + @QtCore.Slot() def sync(self): """ Synchronizes the model with the current state of the databases. """ + logger.debug(f"Syncing {self.__class__.__name__}") + df = self.build_df() self.model.set_dataframe(df) self.view.resizeColumnToContents(1) diff --git a/activity_browser/app/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py index f15a5763d..bb811af75 100644 --- a/activity_browser/app/panes/impact_categories.py +++ b/activity_browser/app/panes/impact_categories.py @@ -1,5 +1,5 @@ -from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt +from qtpy import QtWidgets, QtCore +from loguru import logger import bw2data as bd import pandas as pd @@ -45,6 +45,8 @@ def connect_signals(self): app.signals.database_read_only_changed.connect(self.sync) def sync(self): + logger.debug(f"Syncing {self.__class__.__name__}") + df = self.build_df() self.model.set_dataframe(df, group=["_method_name"]) From d1440f06f0e805c7eff71fb864520a2cca12c414 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sat, 6 Dec 2025 15:02:22 +0100 Subject: [PATCH 185/267] Fix formula edit import bug --- activity_browser/ui/widgets/formula_edit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/activity_browser/ui/widgets/formula_edit.py b/activity_browser/ui/widgets/formula_edit.py index 5762ad7fb..3adfea37d 100644 --- a/activity_browser/ui/widgets/formula_edit.py +++ b/activity_browser/ui/widgets/formula_edit.py @@ -11,7 +11,7 @@ from activity_browser.static import fonts -QFontDatabase.addApplicationFont(fonts.__path__[0] + "/mono.ttf") + operators = r"+\-*/%=<>!&|^~" pattern = r"\b[a-zA-Z_]\w*\b|[\d.]+|[\"'{}:,+\-*/^()\[\]]| +" @@ -57,6 +57,8 @@ class Colors: class ABFormulaEdit(QWidget): def __init__(self, parent=None, scope=None, text=None, simple=False): + QFontDatabase.addApplicationFont(fonts.__path__[0] + "/mono.ttf") + super().__init__(parent) self.scope = scope or {} self.error = False From 7bed7f455e06a93c4675c4ca28fef44e464caf32 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sat, 6 Dec 2025 15:02:32 +0100 Subject: [PATCH 186/267] Fix search test import bug --- tests/test_search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_search.py b/tests/test_search.py index 0c40f4340..f849502c3 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,6 +1,6 @@ import pytest import pandas as pd -from activity_browser.bwutils import SearchEngine +from activity_browser.bwutils.searchengine import SearchEngine def data_for_test(): From 6eb104e6e387308d9e34910fc74e7f02137a7184 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sat, 6 Dec 2025 15:02:51 +0100 Subject: [PATCH 187/267] Fix windows seg fault in testing --- activity_browser/bwutils/searchengine/base.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index 42a8fb8dd..a142ab50f 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -4,6 +4,7 @@ import multiprocessing as mp import re import sys +import threading from collections import Counter, OrderedDict, defaultdict from typing import Iterable, Optional from time import time @@ -204,16 +205,23 @@ def words_in_df(self, df: pd.DataFrame = None) -> tuple[dict, pd.DataFrame]: df["query_col"] = df.iloc[:, self.searchable_columns].astype(str).agg(" | ".join, axis=1) # clean all text at once using vectorized operations df["query_col"] = self.df_clean(df.loc[:, ["query_col"]]) - # build the identifier_word_dict dictionary - identifier_word_dict = df["query_col"].apply(lambda text: Counter(text.split(" "))).to_dict() + # build the identifier_word_dict dictionary - filter out empty strings + identifier_word_dict = df["query_col"].apply( + lambda text: Counter(word for word in text.split(" ") if word) + ).to_dict() return identifier_word_dict, df def reverse_dict_many_to_one(self, dictionary: dict) -> dict: """Reverse a dictionary of Counter objects.""" + logger.debug(f"reverse_dict_many_to_one called with {len(dictionary)} items") reverse = defaultdict(Counter) for identifier, counter_object in dictionary.items(): + if not isinstance(counter_object, Counter): + logger.warning(f"Skipping non-Counter object for {identifier}: {type(counter_object)}") + continue for countable, count in counter_object.items(): - reverse[countable][identifier] += count + if countable: # skip empty strings + reverse[countable][identifier] += count return dict(reverse) def list_to_q_grams(self, word_list: Iterable) -> dict: @@ -315,8 +323,10 @@ def remove_identifier(self, identifier, logging=True) -> None: # make sure the identifier exists if identifier not in self.df.index.to_list(): - raise Exception( - f"Identifier '{identifier}' does not exist in the search data, cannot remove identifier that do not exist.") + logger.warning( + f"Identifier '{identifier}' does not exist in the search data, cannot remove identifier that do not exist." + ) + return self.df = self.df.drop(identifier) From 2b79254c79559ad51ec96804f2603c972bbb24e1 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Sat, 6 Dec 2025 15:13:42 +0100 Subject: [PATCH 188/267] Changed widget debug log --- .../app/dialogs/import_preview_dialog/edge_tab.py | 2 +- .../app/dialogs/import_preview_dialog/node_tab.py | 2 +- activity_browser/app/main_window.py | 2 +- activity_browser/app/menu_bar.py | 2 +- .../app/pages/activity_details/activity_details.py | 2 +- .../app/pages/activity_details/activity_header.py | 2 +- .../app/pages/activity_details/consumers_tab.py | 2 +- activity_browser/app/pages/activity_details/data_tab.py | 2 +- .../app/pages/activity_details/description_tab.py | 2 +- .../app/pages/activity_details/exchanges_tab.py | 2 +- activity_browser/app/pages/activity_details/graph_tab.py | 2 +- .../app/pages/activity_details/parameters_tab.py | 2 +- .../app/pages/calculation_setup/calculation_setup.py | 2 +- .../app/pages/calculation_setup/functional_unit_section.py | 2 +- .../app/pages/calculation_setup/impact_category_section.py | 2 +- .../impact_category_details/impact_category_details.py | 2 +- .../pages/impact_category_details/impact_category_header.py | 6 +++--- activity_browser/app/pages/metadatastore.py | 2 +- .../app/pages/parameters/parameterized_exchanges_section.py | 2 +- activity_browser/app/pages/parameters/parameters_section.py | 2 +- activity_browser/app/pages/settings/project_manager.py | 3 +-- activity_browser/app/panes/calculation_setups.py | 2 +- activity_browser/app/panes/database_products.py | 2 +- activity_browser/app/panes/databases.py | 2 +- activity_browser/app/panes/impact_categories.py | 2 +- 25 files changed, 27 insertions(+), 28 deletions(-) diff --git a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py index 3fd759b7b..bb9605e68 100644 --- a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py +++ b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py @@ -51,7 +51,7 @@ def __init__(self, importer: LCIImporter, parent=None): def sync(self): """Synchronize the view based on simple/detailed mode.""" - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") self.edge_view.header().setHidden(self.simple) self.edge_view.viewport().setBackgroundRole( diff --git a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py index 2d2199a8b..6afe7fbff 100644 --- a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py +++ b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py @@ -47,7 +47,7 @@ def __init__(self, importer: LCIImporter, parent=None): def sync(self): """Synchronize the view based on simple/detailed mode.""" - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") self.node_view.header().setHidden(self.simple) self.node_view.viewport().setBackgroundRole( diff --git a/activity_browser/app/main_window.py b/activity_browser/app/main_window.py index 0940fc916..87117c64e 100644 --- a/activity_browser/app/main_window.py +++ b/activity_browser/app/main_window.py @@ -52,7 +52,7 @@ def __init__(self, parent=None): self.connect_signals() def sync(self): - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") self.sync_panes() self.sync_pages() diff --git a/activity_browser/app/menu_bar.py b/activity_browser/app/menu_bar.py index 587f3ad4f..64b86e3c0 100644 --- a/activity_browser/app/menu_bar.py +++ b/activity_browser/app/menu_bar.py @@ -167,7 +167,7 @@ def __init__(self, parent=None) -> None: app.signals.meta.calculation_setups_changed.connect(self.sync) def sync(self): - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") self.cs_actions.clear() for cs in bd.calculation_setups: diff --git a/activity_browser/app/pages/activity_details/activity_details.py b/activity_browser/app/pages/activity_details/activity_details.py index b4a19bda6..b7f73f03d 100644 --- a/activity_browser/app/pages/activity_details/activity_details.py +++ b/activity_browser/app/pages/activity_details/activity_details.py @@ -148,7 +148,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") self.activity = refresh_node_or_none(self.activity) diff --git a/activity_browser/app/pages/activity_details/activity_header.py b/activity_browser/app/pages/activity_details/activity_header.py index e506607cf..6ac523487 100644 --- a/activity_browser/app/pages/activity_details/activity_header.py +++ b/activity_browser/app/pages/activity_details/activity_header.py @@ -39,7 +39,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") self.activity = refresh_node(self.activity) diff --git a/activity_browser/app/pages/activity_details/consumers_tab.py b/activity_browser/app/pages/activity_details/consumers_tab.py index 389a028bb..13d26e9f8 100644 --- a/activity_browser/app/pages/activity_details/consumers_tab.py +++ b/activity_browser/app/pages/activity_details/consumers_tab.py @@ -52,7 +52,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") self.activity = refresh_node(self.activity) exchanges = [] diff --git a/activity_browser/app/pages/activity_details/data_tab.py b/activity_browser/app/pages/activity_details/data_tab.py index c8f7d1924..93ebc062f 100644 --- a/activity_browser/app/pages/activity_details/data_tab.py +++ b/activity_browser/app/pages/activity_details/data_tab.py @@ -56,7 +56,7 @@ def sync(self) -> None: """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") self.activity = refresh_node(self.activity) df = self.build_df() diff --git a/activity_browser/app/pages/activity_details/description_tab.py b/activity_browser/app/pages/activity_details/description_tab.py index 71f97ebde..03a2c7af5 100644 --- a/activity_browser/app/pages/activity_details/description_tab.py +++ b/activity_browser/app/pages/activity_details/description_tab.py @@ -30,7 +30,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") self.activity = refresh_node(self.activity) self.setText(self.activity.get("comment", "")) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index 973ca1210..91690945b 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -97,7 +97,7 @@ def sync(self) -> None: """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") # Refresh the activity node self.activity = refresh_node(self.activity) diff --git a/activity_browser/app/pages/activity_details/graph_tab.py b/activity_browser/app/pages/activity_details/graph_tab.py index 796005ce2..cd3bb1063 100644 --- a/activity_browser/app/pages/activity_details/graph_tab.py +++ b/activity_browser/app/pages/activity_details/graph_tab.py @@ -73,7 +73,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") self.activity = refresh_node(self.activity) json = self.build_json() diff --git a/activity_browser/app/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py index c4596f957..1605f3ab0 100644 --- a/activity_browser/app/pages/activity_details/parameters_tab.py +++ b/activity_browser/app/pages/activity_details/parameters_tab.py @@ -59,7 +59,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") self.activity = refresh_node(self.activity) df = self.build_df() diff --git a/activity_browser/app/pages/calculation_setup/calculation_setup.py b/activity_browser/app/pages/calculation_setup/calculation_setup.py index beb0ffe6f..1bc590923 100644 --- a/activity_browser/app/pages/calculation_setup/calculation_setup.py +++ b/activity_browser/app/pages/calculation_setup/calculation_setup.py @@ -71,7 +71,7 @@ def connect_signals(self): self.run_button.released.connect(self.run_calculation) def sync(self) -> None: - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") self.functional_unit_section.sync() self.impact_category_section.sync() diff --git a/activity_browser/app/pages/calculation_setup/functional_unit_section.py b/activity_browser/app/pages/calculation_setup/functional_unit_section.py index b6d980a58..cbaed45d1 100644 --- a/activity_browser/app/pages/calculation_setup/functional_unit_section.py +++ b/activity_browser/app/pages/calculation_setup/functional_unit_section.py @@ -29,7 +29,7 @@ def build_layout(self): self.setLayout(layout) def sync(self): - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") try: self.calculation_setup = bd.calculation_setups[self.calculation_setup_name] diff --git a/activity_browser/app/pages/calculation_setup/impact_category_section.py b/activity_browser/app/pages/calculation_setup/impact_category_section.py index c54906a91..1704ccd3d 100644 --- a/activity_browser/app/pages/calculation_setup/impact_category_section.py +++ b/activity_browser/app/pages/calculation_setup/impact_category_section.py @@ -27,7 +27,7 @@ def build_layout(self): self.setLayout(layout) def sync(self): - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") try: self.calculation_setup = bd.calculation_setups[self.calculation_setup_name] diff --git a/activity_browser/app/pages/impact_category_details/impact_category_details.py b/activity_browser/app/pages/impact_category_details/impact_category_details.py index 74ccbf9e7..49a29fc64 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_details.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_details.py @@ -47,7 +47,7 @@ def on_method_deleted(self, method): self.deleteLater() def sync(self): - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") if self.name not in bd.methods: self.deleteLater() diff --git a/activity_browser/app/pages/impact_category_details/impact_category_header.py b/activity_browser/app/pages/impact_category_details/impact_category_header.py index 12684709a..bc9db93f7 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_header.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_header.py @@ -44,7 +44,7 @@ def sync(self): Synchronizes the widget with the current state of the impact category. Switches between editable and view-only headers based on edit mode. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") self.impact_category = self.parent().impact_category @@ -105,7 +105,7 @@ def sync(self): """ Updates the displayed information from the current impact category. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") impact_category = self.parent().impact_category self.name_label.setText(" | ".join(impact_category.name)) @@ -151,7 +151,7 @@ def sync(self): """ Updates the displayed information from the current impact category. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") impact_category = self.parent().impact_category self.name_label.setText(f"{' | '.join(impact_category.name)}") diff --git a/activity_browser/app/pages/metadatastore.py b/activity_browser/app/pages/metadatastore.py index 5f0d5d751..2909e8bee 100644 --- a/activity_browser/app/pages/metadatastore.py +++ b/activity_browser/app/pages/metadatastore.py @@ -21,7 +21,7 @@ def connect_signals(self): signals.metadata.synced.connect(self.sync) def sync(self): - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") self.model.set_dataframe(metadata.dataframe) def build_layout(self): diff --git a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py index 6ee83d172..d5f37d373 100644 --- a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py +++ b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py @@ -64,7 +64,7 @@ def sync(self): """ Synchronizes the widget with the current state of parameterized exchanges. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") df = self.build_exchanges_df() self.model.set_dataframe(df) diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py index 28aa6e2a1..86de24463 100644 --- a/activity_browser/app/pages/parameters/parameters_section.py +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -68,7 +68,7 @@ def sync(self): """ Synchronizes the widget with the current state of parameters. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") df = self.build_df() self.model.set_dataframe(df, group=["_param_type", "_scope"]) diff --git a/activity_browser/app/pages/settings/project_manager.py b/activity_browser/app/pages/settings/project_manager.py index 5ae30413a..a4aa2abee 100644 --- a/activity_browser/app/pages/settings/project_manager.py +++ b/activity_browser/app/pages/settings/project_manager.py @@ -45,12 +45,11 @@ def build_layout(self): def connect_signals(self): """Connect signals and slots.""" - app.signals.project.changed.connect(self.sync) app.signals.project.deleted.connect(self.sync) def sync(self): """Sync project and template data.""" - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") df = self.build_project_df() self.project_model.set_dataframe(df) diff --git a/activity_browser/app/panes/calculation_setups.py b/activity_browser/app/panes/calculation_setups.py index 092c2fffc..ba5ab3d4a 100644 --- a/activity_browser/app/panes/calculation_setups.py +++ b/activity_browser/app/panes/calculation_setups.py @@ -53,7 +53,7 @@ def sync(self): """ Synchronizes the model with the current state of the calculation setups. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") df = self.build_df() self.model.set_dataframe(df) self.view.resizeColumnToContents(0) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index e72f1d494..0ef44f752 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -149,7 +149,7 @@ def sync(self): """ Synchronizes the widget with the current state of the database. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") t = time() df = self.build_df() diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py index 06ad2633f..1ab444949 100644 --- a/activity_browser/app/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -65,7 +65,7 @@ def sync(self): """ Synchronizes the model with the current state of the databases. """ - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") df = self.build_df() self.model.set_dataframe(df) diff --git a/activity_browser/app/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py index bb811af75..7f049c3b3 100644 --- a/activity_browser/app/panes/impact_categories.py +++ b/activity_browser/app/panes/impact_categories.py @@ -45,7 +45,7 @@ def connect_signals(self): app.signals.database_read_only_changed.connect(self.sync) def sync(self): - logger.debug(f"Syncing {self.__class__.__name__}") + logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") df = self.build_df() self.model.set_dataframe(df, group=["_method_name"]) From 601a68aebd81085ce66a0a61b8618cd9add2a80c Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 8 Dec 2025 11:26:27 +0100 Subject: [PATCH 189/267] No mp in base searchengine --- activity_browser/bwutils/searchengine/base.py | 31 ++----------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index a142ab50f..aa6acce57 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -1,10 +1,7 @@ import itertools import functools -import math -import multiprocessing as mp import re import sys -import threading from collections import Counter, OrderedDict, defaultdict from typing import Iterable, Optional from time import time @@ -164,37 +161,13 @@ def text_to_positional_q_gram(self, text: str) -> list: return [text] return list(text[i:i + q] for i in range(n - q + 1)) - def df_clean_worker(self, df): - """Clean the text in query_col.""" - df["query_col"] = df["query_col"].apply(self.clean_text) - return df - def df_clean(self, df): """Clean the text in query_col. apply multi-processing when the computer is able and its relevant """ - def chunk_dataframe(df: pd.DataFrame, chunk_size: int): - """Split DataFrame into chunks of specified size.""" - return [df.iloc[i:i + chunk_size] for i in range(0, len(df), chunk_size)] - - max_cores = max(1, mp.cpu_count() - 1) # leave at least 1 core for other processes - min_chunk_size = 2500 - if max_cores > 1 and len(df) > min_chunk_size * 2: - for i in range(max_cores, 0, -1): - chunk_size = int(math.ceil(len(df) / i)) - if chunk_size >= min_chunk_size: - break - use_cores = i - else: - use_cores = 1 - if use_cores == 1: - return self.df_clean_worker(df) - - chunks = chunk_dataframe(df, chunk_size) - with mp.Pool(processes=use_cores) as pool: - results = pool.starmap(self.df_clean_worker, [(chunk,) for chunk in chunks]) - return pd.concat(results) + df["query_col"] = df["query_col"].apply(self.clean_text) + return df def words_in_df(self, df: pd.DataFrame = None) -> tuple[dict, pd.DataFrame]: """Return a dict of {identifier: word} for df.""" From 4ddbcc2e9294c77fca86818a3bfdd37d6aa1f20c Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 8 Dec 2025 11:26:45 +0100 Subject: [PATCH 190/267] Fix tabwidget init signature --- activity_browser/ui/widgets/tab_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activity_browser/ui/widgets/tab_widget.py b/activity_browser/ui/widgets/tab_widget.py index 590d68fd2..d3a00561b 100644 --- a/activity_browser/ui/widgets/tab_widget.py +++ b/activity_browser/ui/widgets/tab_widget.py @@ -4,7 +4,7 @@ class ABTabWidget(QtWidgets.QTabWidget): - def __init__(self, name: str, *args): + def __init__(self, *args, **kwargs): """ Initialize the GroupTabWidget. @@ -12,7 +12,7 @@ def __init__(self, name: str, *args): name (str): The name of the group, used as the object name for the widget. *args: Additional positional arguments passed to the parent QTabWidget. """ - super().__init__(*args) + super().__init__(*args, **kwargs) self.setMovable(True) # Allow tabs to be rearranged. self.setTabsClosable(True) # Allow tabs to be closed. self.tabBar().setExpanding(False) From 1a124f0abff24a9405bfaaa245d900080a933d7b Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 8 Dec 2025 11:27:54 +0100 Subject: [PATCH 191/267] CTW is part of the main_window --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index ee4497673..48469589e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,7 +26,7 @@ def main_window(qtbot, monkeypatch, no_exception_dialogs): app.MainWindow._instance = None # Reset singleton instance for testing main_window = app.MainWindow() - central_widget = CentralTabWidget(main_window) + central_widget = CentralTabWidget(parent=main_window) qtbot.addWidget(main_window) setattr(app.application, "main_window", main_window) From 82f2d1056f077793a7e8586f1a5159fb1adaa3be Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 8 Dec 2025 11:48:56 +0100 Subject: [PATCH 192/267] Deleting initiated pages on MainWindow delete --- activity_browser/app/main_window.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/activity_browser/app/main_window.py b/activity_browser/app/main_window.py index 87117c64e..fa1ba2765 100644 --- a/activity_browser/app/main_window.py +++ b/activity_browser/app/main_window.py @@ -1,7 +1,7 @@ from pathlib import Path from loguru import logger -from qtpy import QtCore, QtWidgets, QtGui +from qtpy import QtCore, QtWidgets import bw2data as bd from activity_browser import app @@ -50,6 +50,18 @@ def __init__(self, parent=None): self.central_widget.tabCloseRequested.connect(self._on_tab_close_requested) self.connect_signals() + self.destroyed.connect(lambda: logger.warning("MainWindow destroyed")) + + def event(self, event): + if event.type() == QtCore.QEvent.Type.DeferredDelete: + for page in self.base_pages.values(): + logger.debug(f"Destroying base page {page.__class__.__name__}: {id(page)}") + try: + page.deleteLater() + except RuntimeError: + # page already deleted + pass + return super().event(event) def sync(self): logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") @@ -246,8 +258,11 @@ def connect_signals(self): def clearPanes(self): for pane in self.panes(): + logger.debug(f"Clearing pane {pane.__class__.__name__}: {id(pane)}") pane.deleteLater() + app.application.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + def panes(self): """ Return a list of all panes in the main window. From 3d630303471e69832f72d95cd78ba475245d696b Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 8 Dec 2025 11:49:30 +0100 Subject: [PATCH 193/267] Don't double-sync CS pane --- activity_browser/app/panes/calculation_setups.py | 1 - 1 file changed, 1 deletion(-) diff --git a/activity_browser/app/panes/calculation_setups.py b/activity_browser/app/panes/calculation_setups.py index ba5ab3d4a..65d4442fb 100644 --- a/activity_browser/app/panes/calculation_setups.py +++ b/activity_browser/app/panes/calculation_setups.py @@ -38,7 +38,6 @@ def connect_signals(self): Connects the signals to the appropriate slots. """ app.signals.meta.calculation_setups_changed.connect(self.sync) - app.signals.project.changed.connect(self.sync) def build_layout(self): """ From 845318152fde0c40c6ced9494c4ebf673b8df957 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 8 Dec 2025 12:07:52 +0100 Subject: [PATCH 194/267] Trying to fix segfault during tests --- activity_browser/bwutils/metadata/loader.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 468b78249..515dddc8f 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -11,7 +11,7 @@ from .fields import secondary_types, primary, secondary, search_engine_whitelist, all_fields -class MDSLoader(): +class MDSLoader: primary_status: Literal["idle", "loading", "done"] = "idle" secondary_status: Literal["idle", "loading", "done"] = "idle" @@ -104,7 +104,8 @@ def secondary_load_project(self, secondary_df: pd.DataFrame, sqlite_db: str): assert all(secondary_df.index.isin(self.mds.dataframe.index)) logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") - self.mds.dataframe = pd.concat([self.mds.dataframe[primary], secondary_df], axis=1) + left = self.mds.dataframe[primary].copy(deep=True) + self.mds.dataframe = pd.concat([left, secondary_df], axis=1) for idx in secondary_df.index: self.mds.register_mutation(idx, "update") From 008c7366f6329888ddb5611dc602beaa21ef033e Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 8 Dec 2025 12:31:36 +0100 Subject: [PATCH 195/267] Trying to fix segfault during tests --- activity_browser/app/main_window.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/activity_browser/app/main_window.py b/activity_browser/app/main_window.py index fa1ba2765..0ca9636e5 100644 --- a/activity_browser/app/main_window.py +++ b/activity_browser/app/main_window.py @@ -261,8 +261,6 @@ def clearPanes(self): logger.debug(f"Clearing pane {pane.__class__.__name__}: {id(pane)}") pane.deleteLater() - app.application.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) - def panes(self): """ Return a list of all panes in the main window. From fad76cb74ca0b132924eb232bc2b05f7818a5350 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 8 Dec 2025 12:32:03 +0100 Subject: [PATCH 196/267] Trying to fix segfault during tests --- activity_browser/bwutils/metadata/loader.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 515dddc8f..ca56085d5 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -115,6 +115,8 @@ def secondary_load_project(self, secondary_df: pd.DataFrame, sqlite_db: str): def load_database(self, database_name: str): from bw2data.backends import sqlite3_lci_db + self.primary_status = "loading" + self.secondary_status = "loading" # start loading thread for secondary metadata thread = SecondaryLoadThread( @@ -143,6 +145,8 @@ def primary_load_database(self, database_name: str): for idx in primary_df.index: self.mds.register_mutation(idx, "add") + self.primary_status = "done" + def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): from bw2data.backends import sqlite3_lci_db @@ -159,7 +163,9 @@ def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") self._fix_categories(secondary_df) - self.mds.dataframe.update(secondary_df) + df_copy = self.mds.dataframe.copy(deep=True) + df_copy.update(secondary_df) + self.mds.dataframe = df_copy for idx in secondary_df.index: self.mds.register_mutation(idx, "update") @@ -169,6 +175,7 @@ def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): df = self.mds.dataframe.loc[self.mds.dataframe["database"] == database, search_engine_cols] self.mds.searcher.add_identifier(df) + self.secondary_status = "done" # utility functions def _fix_categories(self, df: pd.DataFrame): From 57b3161a51784cc53999e582825e60e05ec0d929 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 8 Dec 2025 12:32:22 +0100 Subject: [PATCH 197/267] Don't write empty on database duplicate --- activity_browser/app/actions/database/database_duplicate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activity_browser/app/actions/database/database_duplicate.py b/activity_browser/app/actions/database/database_duplicate.py index 5c8e3db3c..3ca59654b 100644 --- a/activity_browser/app/actions/database/database_duplicate.py +++ b/activity_browser/app/actions/database/database_duplicate.py @@ -91,7 +91,7 @@ def run_safely(self, copy_from, copy_to, backend): metadata = copy.copy(database.metadata) metadata["format"] = f"Copied from '{copy_from}'" metadata["backend"] = backend - new_database.register(**metadata) + new_database.register(write_empty=False, **metadata) if database.backend == "sqlite" and backend == "functional_sqlite": data = bf.convert_sqlite_to_functional_sqlite(data) From 6457b951488aabd86ae02f7ea46fc99fe49ac2cd Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 8 Dec 2025 12:32:41 +0100 Subject: [PATCH 198/267] Wait for full database load during test config --- tests/conftest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 48469589e..1a564abfe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,13 +40,14 @@ def main_window(qtbot, monkeypatch, no_exception_dialogs): yield main_window - # main_window.close() main_window.deleteLater() qtbot.wait(10) @pytest.fixture @bw2test def basic_database(main_window): + import time + from activity_browser.app import metadata from fixtures.basic import DATABASE, METHOD, CALCULATION_SETUP db = bf.FunctionalSQLiteDatabase("basic") @@ -61,5 +62,8 @@ def basic_database(main_window): bd.calculation_setups["basic_calculation_setup"] = CALCULATION_SETUP bd.calculation_setups.flush() + while metadata.loader.secondary_status != "done": + time.sleep(1) + return db From a457374b4ca069fdd4cb178e893c9782bb0f9bc9 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 8 Dec 2025 13:26:49 +0100 Subject: [PATCH 199/267] Fix CS actions using deprecated signals --- .../app/actions/calculation_setup/cs_duplicate.py | 5 +++-- activity_browser/app/actions/calculation_setup/cs_rename.py | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/activity_browser/app/actions/calculation_setup/cs_duplicate.py b/activity_browser/app/actions/calculation_setup/cs_duplicate.py index 87cfab311..aa49be61a 100644 --- a/activity_browser/app/actions/calculation_setup/cs_duplicate.py +++ b/activity_browser/app/actions/calculation_setup/cs_duplicate.py @@ -2,11 +2,12 @@ from qtpy import QtWidgets -from activity_browser.app import application, signals +from activity_browser.app import application from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.mod import bw2data as bd from activity_browser.ui.icons import qicons +from .cs_open import CSOpen @@ -44,5 +45,5 @@ def run(cs_name: str): return bd.calculation_setups[new_name] = bd.calculation_setups[cs_name].copy() - signals.calculation_setup_selected.emit(new_name) logger.info(f"Copied calculation setup {cs_name} as {new_name}") + CSOpen.run(new_name) diff --git a/activity_browser/app/actions/calculation_setup/cs_rename.py b/activity_browser/app/actions/calculation_setup/cs_rename.py index 419b6e904..72ad71041 100644 --- a/activity_browser/app/actions/calculation_setup/cs_rename.py +++ b/activity_browser/app/actions/calculation_setup/cs_rename.py @@ -46,5 +46,4 @@ def run(cs_name: str, new_name: str = None): # instruct the CalculationSetupController to rename the CS to the new name bd.calculation_setups[new_name] = bd.calculation_setups[cs_name].copy() del bd.calculation_setups[cs_name] - signals.calculation_setup_selected.emit(new_name) logger.info(f"Renamed calculation setup from {cs_name} to {new_name}") From fd843ff099f3b77890df0876bfea615324be2df4 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 8 Dec 2025 13:27:03 +0100 Subject: [PATCH 200/267] Delete redundant tests --- .../widgets/test_wizard_button_visibility.py | 81 ------------------- 1 file changed, 81 deletions(-) delete mode 100644 tests/widgets/test_wizard_button_visibility.py diff --git a/tests/widgets/test_wizard_button_visibility.py b/tests/widgets/test_wizard_button_visibility.py deleted file mode 100644 index 21bb2a710..000000000 --- a/tests/widgets/test_wizard_button_visibility.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Test wizard button visibility based on page.buttons property""" -import pytest -from qtpy import QtWidgets -from activity_browser.ui.widgets import ABWizard, ABWizardPage - - -class TestPage1(ABWizardPage): - """Test page with custom button configuration""" - title = "Page 1" - buttons = [ - QtWidgets.QWizard.WizardButton.NextButton, - QtWidgets.QWizard.WizardButton.CancelButton, - ] - - -class TestPage2(ABWizardPage): - """Test page with different button configuration""" - title = "Page 2" - buttons = [ - QtWidgets.QWizard.WizardButton.BackButton, - QtWidgets.QWizard.WizardButton.FinishButton, - QtWidgets.QWizard.WizardButton.CancelButton, - ] - - -class TestPage3(ABWizardPage): - """Test page using default button configuration""" - title = "Page 3" - - -class TestWizard(ABWizard): - """Test wizard with multiple pages""" - pages = [TestPage1, TestPage2, TestPage3] - - -def test_wizard_button_visibility_first_page(qtbot): - """Test that buttons are visible/hidden correctly on the first page""" - wizard = TestWizard() - qtbot.addWidget(wizard) - - # Initialize first page - wizard.restart() - - # Check button visibility for first page (only Next and Cancel should be visible) - assert not wizard.button(QtWidgets.QWizard.WizardButton.BackButton).isVisible() - assert wizard.button(QtWidgets.QWizard.WizardButton.NextButton).isVisible() - assert wizard.button(QtWidgets.QWizard.WizardButton.CancelButton).isVisible() - assert not wizard.button(QtWidgets.QWizard.WizardButton.FinishButton).isVisible() - - -def test_wizard_button_visibility_second_page(qtbot): - """Test that buttons are visible/hidden correctly on the second page""" - wizard = TestWizard() - qtbot.addWidget(wizard) - - # Navigate to second page - wizard.restart() - wizard.next() - - # Check button visibility for second page (Back, Finish, Cancel should be visible) - assert wizard.button(QtWidgets.QWizard.WizardButton.BackButton).isVisible() - assert not wizard.button(QtWidgets.QWizard.WizardButton.NextButton).isVisible() - assert wizard.button(QtWidgets.QWizard.WizardButton.CancelButton).isVisible() - assert wizard.button(QtWidgets.QWizard.WizardButton.FinishButton).isVisible() - - -def test_wizard_button_visibility_third_page_default(qtbot): - """Test that default buttons are shown when page.buttons is not customized""" - wizard = TestWizard() - qtbot.addWidget(wizard) - - # Navigate to third page (uses default buttons) - wizard.restart() - wizard.next() - wizard.next() - - # Check that default buttons are visible (Back, Next, Cancel from ABWizardPage default) - assert wizard.button(QtWidgets.QWizard.WizardButton.BackButton).isVisible() - assert wizard.button(QtWidgets.QWizard.WizardButton.NextButton).isVisible() - assert wizard.button(QtWidgets.QWizard.WizardButton.CancelButton).isVisible() - From 2683d99227c0d96f6f4386342fbbe6813748aa0b Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 8 Dec 2025 15:20:48 +0100 Subject: [PATCH 201/267] Fixing tests --- activity_browser/app/__init__.py | 8 +- .../app/{main_window.py => main.py} | 0 tests/conftest.py | 27 ++-- tests/test_mds_cross_database.py | 119 ++++++++++++++++++ tests/test_search.py | 8 +- 5 files changed, 139 insertions(+), 23 deletions(-) rename activity_browser/app/{main_window.py => main.py} (100%) create mode 100644 tests/test_mds_cross_database.py diff --git a/activity_browser/app/__init__.py b/activity_browser/app/__init__.py index 95bf10576..144e33319 100644 --- a/activity_browser/app/__init__.py +++ b/activity_browser/app/__init__.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- __all__ = ["panes", "pages", "application", "signals", "metadata", "main_window", "actions"] +import os + from activity_browser.ui.core.application import ABApplication from activity_browser.bwutils.metadata import MetaDataStore from activity_browser.bwutils.settings import Settings -from .main_window import MainWindow +from .main import MainWindow application = ABApplication() metadata = MetaDataStore() @@ -23,5 +25,7 @@ main_window = MainWindow() application.main_window = main_window -main_window.apply_settings(load=True) # Ensure settings are applied at startup + +if not os.environ.get("AB_SKIP_SETTINGS_ON_STARTUP"): + main_window.apply_settings(load=True) # Ensure settings are applied at startup diff --git a/activity_browser/app/main_window.py b/activity_browser/app/main.py similarity index 100% rename from activity_browser/app/main_window.py rename to activity_browser/app/main.py diff --git a/tests/conftest.py b/tests/conftest.py index 1a564abfe..54e0a28de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,15 @@ from copy import deepcopy +from importlib import reload import pytest +import os import bw2data as bd import bw_functional as bf from bw2data.tests import bw2test +os.environ["AB_SKIP_SETTINGS_ON_STARTUP"] = "1" + @pytest.fixture def no_exception_dialogs(monkeypatch): @@ -21,26 +25,19 @@ def no_exception_dialogs(monkeypatch): def main_window(qtbot, monkeypatch, no_exception_dialogs): """Return the main window of the application instance.""" from activity_browser import app - from activity_browser.app import pages - from activity_browser.ui.widgets import CentralTabWidget - - app.MainWindow._instance = None # Reset singleton instance for testing - main_window = app.MainWindow() - central_widget = CentralTabWidget(parent=main_window) + from activity_browser.bwutils.metadata import metadata - qtbot.addWidget(main_window) - setattr(app.application, "main_window", main_window) - setattr(app, "main_window", main_window) + # Reload modules to ensure a clean state for each test + reload(metadata) + reload(app.main) + reload(app) - # central_widget.addTab(pages.WelcomePage(), "Welcome") - central_widget.addTab(pages.ParametersPage(), "Parameters") - - main_window.setCentralWidget(central_widget) - main_window.show() + app.main_window.show() yield main_window - main_window.deleteLater() + app.main_window.deleteLater() + qtbot.wait(10) @pytest.fixture diff --git a/tests/test_mds_cross_database.py b/tests/test_mds_cross_database.py new file mode 100644 index 000000000..b183759cd --- /dev/null +++ b/tests/test_mds_cross_database.py @@ -0,0 +1,119 @@ +"""Tests for MDSSearcher cross-database functionality.""" +import pytest +import pandas as pd +from activity_browser.bwutils.metadata.searcher import MDSSearcher +from activity_browser.bwutils.metadata.metadata import MetaDataStore + + +@pytest.fixture +def multi_db_mds(): + """Create a MetaDataStore with multiple databases.""" + test_data = pd.DataFrame([ + [1, "db1", "coal production", "coal", "process", "", "", "", ""], + [2, "db1", "coal mining", "coal", "process", "", "", "", ""], + [3, "db1", "steel production", "steel", "process", "", "", "", ""], + [4, "db2", "coal transport", "transport", "process", "", "", "", ""], + [5, "db2", "electricity from coal", "electricity", "process", "", "", "", ""], + [6, "db3", "coal combustion", "heat", "process", "", "", "", ""], + [7, "db3", "gas production", "gas", "process", "", "", "", ""], + ], columns=["id", "database", "name", "reference product", "type", "location", "unit", "comment", "tags"]) + + mds = MetaDataStore() + mds.dataframe = test_data + return mds + + +def test_search_single_database(multi_db_mds): + """Test searching within a single database.""" + searcher = MDSSearcher(multi_db_mds) + + # Search for "coal" in db1 + results = searcher.search("coal", database="db1") + assert len(results) == 2 + assert set(results) == {1, 2} + + # Search for "coal" in db2 + results = searcher.search("coal", database="db2") + assert len(results) == 2 + assert set(results) == {4, 5} + + # Search for "coal" in db3 + results = searcher.search("coal", database="db3") + assert len(results) == 1 + assert set(results) == {6} + + +def test_search_all_databases(multi_db_mds): + """Test searching across all databases when database=None.""" + searcher = MDSSearcher(multi_db_mds) + + # Search for "coal" across all databases + results = searcher.search("coal", database=None) + assert len(results) == 5 + assert set(results) == {1, 2, 4, 5, 6} + + # Search for "production" across all databases + results = searcher.search("production", database=None) + assert len(results) == 3 + assert set(results) == {1, 3, 7} + + +def test_fuzzy_search_all_databases(multi_db_mds): + """Test fuzzy search across all databases.""" + searcher = MDSSearcher(multi_db_mds) + + # Fuzzy search for "coal" across all databases + results = searcher.fuzzy_search("coal", database=None) + assert len(results) >= 5 + + # Fuzzy search for "production" across all databases + results = searcher.fuzzy_search("production", database=None) + assert len(results) >= 3 + + +def test_search_cache_separation(multi_db_mds): + """Test that search cache properly separates single-db and all-db searches.""" + searcher = MDSSearcher(multi_db_mds) + + # Do searches to populate cache + results_db1 = searcher.search("coal", database="db1") + results_all = searcher.search("coal", database=None) + + # Verify results are different + assert len(results_db1) == 2 + assert len(results_all) == 5 + assert set(results_db1).issubset(set(results_all)) + + # Search again to use cached results + results_3ached = searcher.search("coal", database="db1") + results_all_cached = searcher.search("coal", database=None) + + # Verify cached results match original + assert results_db1 == results_3ached + assert results_all == results_all_cached + + +def test_auto_complete_all_databases(multi_db_mds): + """Test autocomplete across all databases.""" + searcher = MDSSearcher(multi_db_mds) + + # Autocomplete for "coa" across all databases + completions = searcher.auto_complete("coa", database=None) + assert "coal" in completions + + # Autocomplete for "prod" in specific database + completions_db1 = searcher.auto_complete("prod", database="db1") + assert "production" in completions_db1 + + # Autocomplete for "prod" across all databases + completions_all = searcher.auto_complete("prod", database=None) + assert "production" in completions_all + + +def test_empty_search_all_databases(multi_db_mds): + """Test empty search returns all items when database=None.""" + searcher = MDSSearcher(multi_db_mds) + + results = searcher.search("", database=None) + assert len(results) == 7 # All items in all databases + diff --git a/tests/test_search.py b/tests/test_search.py index f849502c3..58e344037 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -184,15 +184,11 @@ def test_search_add_identifier(): assert se.search("coal production") == ["i", "a", "c", "b", "d", "h", "f", "g"] -def test_search_remove_identifier(): +def test_search_remove_identifier(caplog): """Do tests for removing identifier.""" + caplog.set_level("WARNING") df = data_for_test() - # use non-existent identifier and fail - se = SearchEngine(df, identifier_name="id") - with pytest.raises(Exception): - se.remove_identifier(identifier="i") - # do search, remove item and verify results are different se = SearchEngine(df, identifier_name="id") assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] From 1c245ac98f117068cd681e53358c8ec97a2f16ca Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Mon, 8 Dec 2025 16:51:24 +0100 Subject: [PATCH 202/267] Fixing tests --- activity_browser/app/main.py | 1 + activity_browser/app/panes/databases.py | 1 + activity_browser/app/signalling.py | 2 +- activity_browser/bwutils/metadata/loader.py | 16 +++++++--------- activity_browser/bwutils/metadata/metadata.py | 2 ++ tests/conftest.py | 10 ++++++++-- 6 files changed, 20 insertions(+), 12 deletions(-) diff --git a/activity_browser/app/main.py b/activity_browser/app/main.py index 0ca9636e5..98302001b 100644 --- a/activity_browser/app/main.py +++ b/activity_browser/app/main.py @@ -266,6 +266,7 @@ def panes(self): Return a list of all panes in the main window. """ from activity_browser.ui import widgets + QtWidgets.QApplication.processEvents() return self.findChildren(widgets.ABAbstractPane) def set_titlebar(self): diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py index 1ab444949..ae9e15fdf 100644 --- a/activity_browser/app/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -31,6 +31,7 @@ def __init__(self, parent): Args: parent (QtWidgets.QWidget): The parent widget. """ + logger.debug(f"Initializing DatabasesPane: {id(self)}") super().__init__(parent) self.model = DatabasesModel(parent=self) self.view = DatabasesView() diff --git a/activity_browser/app/signalling.py b/activity_browser/app/signalling.py index 73807a272..9ead8c0ac 100644 --- a/activity_browser/app/signalling.py +++ b/activity_browser/app/signalling.py @@ -1,7 +1,7 @@ from loguru import logger from time import time -from qtpy.QtCore import QObject, Signal, SignalInstance, QTimer +from qtpy.QtCore import QObject, Signal, SignalInstance, QTimer, QEvent from blinker import signal as blinker_signal diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index ca56085d5..b2bc15b6f 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -42,12 +42,12 @@ def load_project(self): return # start loading thread for secondary metadata - thread = SecondaryLoadThread( + self.thread = SecondaryLoadThread( databases=list(bd.databases), sqlite_db=str(sqlite3_lci_db._filepath), callback=self.secondary_load_project ) - thread.start() + self.thread.start() # load primary metadata in the main thread self.primary_load_project() @@ -75,8 +75,8 @@ def cache_load_project(self): self.primary_status = "done" self.secondary_status = "done" - thread = threading.Thread(target=self._init_searcher) - thread.start() + self.thread = threading.Thread(target=self._init_searcher) + self.thread.start() def primary_load_project(self): from bw2data.backends import sqlite3_lci_db @@ -119,12 +119,12 @@ def load_database(self, database_name: str): self.secondary_status = "loading" # start loading thread for secondary metadata - thread = SecondaryLoadThread( + self.thread = SecondaryLoadThread( databases=[database_name], sqlite_db=str(sqlite3_lci_db._filepath), callback=self.secondary_load_database ) - thread.start() + self.thread.start() # load primary metadata in the main thread self.primary_load_database(database_name) @@ -160,7 +160,7 @@ def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): logger.debug("Secondary database metadata dropping rows") secondary_df = secondary_df[secondary_df.index.isin(indices)] - logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") + logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows, adding to mds {id(self.mds)}") self._fix_categories(secondary_df) df_copy = self.mds.dataframe.copy(deep=True) @@ -262,7 +262,6 @@ def __init__(self, databases: list[str], sqlite_db: str, callback: Callable): self.databases = databases self.sqlite_db = sqlite_db self.callback = callback - self.result_df = None def run(self): """Execute the loading in a background thread.""" @@ -278,7 +277,6 @@ def run(self): full_df = pd.concat([full_df, df]) # Store result and call callback - self.result_df = full_df self.callback(full_df, self.sqlite_db) except Exception as e: diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index ec562c898..2a88b7f49 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -20,6 +20,8 @@ def __init__(self): from .updater import MDSUpdater from .searcher import MDSSearcher + logger.debug(f"Initializing MetaDataStore: {id(self)}") + if self._initialized: return self._initialized = True diff --git a/tests/conftest.py b/tests/conftest.py index 54e0a28de..8b4e2a481 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,13 @@ from copy import deepcopy from importlib import reload +import pandas as pd import pytest import os import bw2data as bd +from PySide6 import QtCore + import bw_functional as bf from bw2data.tests import bw2test @@ -21,7 +24,7 @@ def no_exception_dialogs(monkeypatch): # No need to undo the monkeypatch, pytest does it automatically -@pytest.fixture() +@pytest.fixture def main_window(qtbot, monkeypatch, no_exception_dialogs): """Return the main window of the application instance.""" from activity_browser import app @@ -31,6 +34,7 @@ def main_window(qtbot, monkeypatch, no_exception_dialogs): reload(metadata) reload(app.main) reload(app) + metadata.df = pd.DataFrame() app.main_window.show() @@ -42,11 +46,13 @@ def main_window(qtbot, monkeypatch, no_exception_dialogs): @pytest.fixture @bw2test -def basic_database(main_window): +def basic_database(qapp, main_window): import time from activity_browser.app import metadata from fixtures.basic import DATABASE, METHOD, CALCULATION_SETUP + qapp.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + db = bf.FunctionalSQLiteDatabase("basic") db.write(deepcopy(DATABASE), process=False) db.metadata["dirty"] = True From 412ae1f5e14e4c03855541489f9c68620904e0d9 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 10:19:04 +0100 Subject: [PATCH 203/267] Fixing tests - copy mdf --- activity_browser/bwutils/metadata/loader.py | 33 ++++++++++--------- activity_browser/bwutils/metadata/metadata.py | 15 +++++---- activity_browser/bwutils/metadata/updater.py | 24 +++++++++----- 3 files changed, 43 insertions(+), 29 deletions(-) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index b2bc15b6f..a7d5371cf 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -32,6 +32,7 @@ def on_project_changed(self, sender): def load_project(self): import bw2data as bd from bw2data.backends import sqlite3_lci_db + # set statuses self.primary_status = "loading" self.secondary_status = "loading" @@ -42,12 +43,12 @@ def load_project(self): return # start loading thread for secondary metadata - self.thread = SecondaryLoadThread( + thread = SecondaryLoadThread( databases=list(bd.databases), sqlite_db=str(sqlite3_lci_db._filepath), callback=self.secondary_load_project ) - self.thread.start() + thread.start() # load primary metadata in the main thread self.primary_load_project() @@ -75,8 +76,8 @@ def cache_load_project(self): self.primary_status = "done" self.secondary_status = "done" - self.thread = threading.Thread(target=self._init_searcher) - self.thread.start() + thread = threading.Thread(target=self._init_searcher) + thread.start() def primary_load_project(self): from bw2data.backends import sqlite3_lci_db @@ -104,7 +105,8 @@ def secondary_load_project(self, secondary_df: pd.DataFrame, sqlite_db: str): assert all(secondary_df.index.isin(self.mds.dataframe.index)) logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") - left = self.mds.dataframe[primary].copy(deep=True) + left = self.mds.get_metadata(columns=primary) + self.mds.dataframe = pd.concat([left, secondary_df], axis=1) for idx in secondary_df.index: @@ -119,12 +121,12 @@ def load_database(self, database_name: str): self.secondary_status = "loading" # start loading thread for secondary metadata - self.thread = SecondaryLoadThread( + thread = SecondaryLoadThread( databases=[database_name], sqlite_db=str(sqlite3_lci_db._filepath), callback=self.secondary_load_database ) - self.thread.start() + thread.start() # load primary metadata in the main thread self.primary_load_database(database_name) @@ -160,12 +162,12 @@ def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): logger.debug("Secondary database metadata dropping rows") secondary_df = secondary_df[secondary_df.index.isin(indices)] - logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows, adding to mds {id(self.mds)}") + logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows, adding to metadatastore {id(self.mds)}") - self._fix_categories(secondary_df) - df_copy = self.mds.dataframe.copy(deep=True) - df_copy.update(secondary_df) - self.mds.dataframe = df_copy + df = self.mds.dataframe + self._fix_categories(secondary_df, df) + df.update(secondary_df) + self.mds.dataframe = df for idx in secondary_df.index: self.mds.register_mutation(idx, "update") @@ -178,15 +180,16 @@ def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): self.secondary_status = "done" # utility functions - def _fix_categories(self, df: pd.DataFrame): + @staticmethod + def _fix_categories(df: pd.DataFrame, mds_df: pd.DataFrame): category_columns = [k for k, v in secondary_types.items() if v == "category"] for col in category_columns: categories = df[col].dropna().unique() - categories = [c for c in categories if c not in self.mds.dataframe[col].cat.categories] + categories = [c for c in categories if c not in mds_df[col].cat.categories] # add new category to column - self.mds.dataframe[col] = self.mds.dataframe[col].cat.add_categories(categories) + mds_df[col] = mds_df[col].cat.add_categories(categories) def _init_searcher(self): from .searcher import MDSSearcher diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 2a88b7f49..0f2bb685a 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -38,7 +38,7 @@ def __init__(self): @property def dataframe(self) -> pd.DataFrame: - return self._dataframe + return self._dataframe.copy() @dataframe.setter def dataframe(self, df: pd.DataFrame) -> None: @@ -109,20 +109,23 @@ def match(self, **kwargs: dict[str, str]) -> pd.DataFrame: return df - def get_metadata(self, keys: list, columns: list = None) -> pd.DataFrame: + def get_metadata(self, keys: list = None, columns: list = None) -> pd.DataFrame: """Return a slice of the dataframe matching row and column identifiers. NOTE: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#deprecate-loc-reindex-listlike From pandas version 1.0 and onwards, attempting to select a column with all NaN values will fail with a KeyError. """ - df = self.dataframe.loc[pd.IndexSlice[keys], :] + keys = keys if keys is not None else self._dataframe.index.tolist() + columns = columns if columns is not None else all_fields + + df = self._dataframe.loc[pd.IndexSlice[keys], :].copy() return df.reindex(columns, axis="columns") def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFrame: if db_name not in self.databases: return pd.DataFrame(columns=columns or all_fields) - return self.dataframe.loc[[db_name], columns or all_fields] + return self._dataframe.loc[[db_name], columns or all_fields].copy() def search(self, query: str, columns: list = None) -> pd.DataFrame: if not self.searcher: @@ -143,7 +146,7 @@ def search_database(self, query: str, database: str, columns: list = None) -> pd return self._meta_from_result(params, result, columns) def _meta_from_result(self, params: dict, result: list[int], columns: list = None) -> pd.DataFrame: - df = self.dataframe.loc[self.dataframe["id"].isin(result), columns or all_fields] + df = self._dataframe.loc[self.dataframe["id"].isin(result), columns or all_fields] df.sort_values(by="id", inplace=True, key=lambda x: x.map({id_: i for i, id_ in enumerate(result)})) extra_query = " & ".join( @@ -156,7 +159,7 @@ def _meta_from_result(self, params: dict, result: list[int], columns: list = Non if extra_query: df = df.query(extra_query) - return df + return df.copy() def auto_complete(self, word: str, context: Optional[set] = None, database: Optional[str] = None): if not self.searcher: diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index 1e66bc07a..75f29b3ba 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -77,8 +77,11 @@ def on_database_changed(self) -> None: # node methods def modify_node(self, ds: pd.Series): - self._fix_categories(ds) - self.mds.dataframe.loc[ds.key] = ds + df = self.mds.dataframe + self._fix_categories(ds, df) + df.loc[ds.key] = ds + + self.mds.dataframe = df self.mds.register_mutation(ds.key, "update") if not hasattr(self.mds, "searcher"): @@ -89,8 +92,12 @@ def modify_node(self, ds: pd.Series): self.mds.searcher.change_identifier(identifier=ds["id"], data=data) def add_node(self, ds: pd.Series): - self._fix_categories(ds) - self.mds.dataframe.loc[ds.key, :] = ds + + df = self.mds.dataframe + self._fix_categories(ds, df) + df.loc[ds.key, :] = ds + + self.mds.dataframe = df self.mds.register_mutation(ds.key, "add") if not hasattr(self.mds, "searcher"): @@ -122,7 +129,7 @@ def delete_database(self, db_name: str): for code in self.mds.dataframe.loc[db_name].index: self.mds.register_mutation((db_name, code), "delete") - ids = self.mds.dataframe.loc[db_name, "id"].tolist() + ids = self.mds.get_database_metadata(db_name, ["id"])["id"].tolist() self.mds.dataframe = self.mds.dataframe.drop(db_name, level=0) @@ -134,7 +141,8 @@ def delete_database(self, db_name: str): self.mds.searcher.reset_all_caches(db_name) # utility functions - def _fix_categories(self, ds: pd.Series): + @staticmethod + def _fix_categories(ds: pd.Series, mds_df: pd.DataFrame): for category_col in [k for k, v in all_types.items() if k in ds and v == "category"]: category = ds[category_col] @@ -142,12 +150,12 @@ def _fix_categories(self, ds: pd.Series): # cannot add NaN as a category continue - if category in self.mds.dataframe[category_col].cat.categories: + if category in mds_df[category_col].cat.categories: # category already exists continue # add new category to column - self.mds.dataframe[category_col] = self.mds.dataframe[category_col].cat.add_categories([category]) + mds_df[category_col] = mds_df[category_col].cat.add_categories([category]) From 929007d96a64b9f650b5354b87ec828b325f8006 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 11:04:04 +0100 Subject: [PATCH 204/267] Fixing tests - thread-lock mdf --- activity_browser/bwutils/metadata/metadata.py | 67 ++++++++++++------- activity_browser/bwutils/metadata/updater.py | 2 - 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 0f2bb685a..464d5d80f 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -1,5 +1,6 @@ from typing import Literal, Optional from loguru import logger +from threading import RLock import pandas as pd @@ -27,6 +28,7 @@ def __init__(self): self._initialized = True self._dataframe = pd.DataFrame() + self._df_lock = RLock() self._added: set[tuple[str, str]] = set() self._updated: set[tuple[str, str]] = set() @@ -38,7 +40,9 @@ def __init__(self): @property def dataframe(self) -> pd.DataFrame: - return self._dataframe.copy() + with self._df_lock: + copy = self._dataframe.copy() + return copy @dataframe.setter def dataframe(self, df: pd.DataFrame) -> None: @@ -52,7 +56,8 @@ def dataframe(self, df: pd.DataFrame) -> None: df[col] = df[col].where(df[col].notnull(), None) # Set the internal dataframe - self._dataframe = df + with self._df_lock: + self._dataframe = df @property def databases(self): @@ -92,20 +97,22 @@ def flush_mutations(self) -> tuple[set[tuple[str, str]], set[tuple[str, str]], s self._deleted.clear() cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" - self.dataframe.to_pickle(cache_path) + with self._df_lock: + self._dataframe.to_pickle(cache_path) return added, updated, deleted def match(self, **kwargs: dict[str, str]) -> pd.DataFrame: """Return a slice of the dataframe matching the criteria. """ - df = self.dataframe.query( - " and ".join( - [ - f"`{key}`.astype('str') == {str(value)!r}" if not pd.isna(value) else f"`{key}`.isnull()" - for key, value in kwargs.items() - ]) - ) + with self._df_lock: + df = self._dataframe.query( + " and ".join( + [ + f"`{key}`.astype('str') == {str(value)!r}" if not pd.isna(value) else f"`{key}`.isnull()" + for key, value in kwargs.items() + ]) + ).copy() return df @@ -119,13 +126,19 @@ def get_metadata(self, keys: list = None, columns: list = None) -> pd.DataFrame: keys = keys if keys is not None else self._dataframe.index.tolist() columns = columns if columns is not None else all_fields - df = self._dataframe.loc[pd.IndexSlice[keys], :].copy() + with self._df_lock: + df = self._dataframe.loc[pd.IndexSlice[keys], :].copy() return df.reindex(columns, axis="columns") def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFrame: + columns = columns if columns is not None else all_fields + if db_name not in self.databases: return pd.DataFrame(columns=columns or all_fields) - return self._dataframe.loc[[db_name], columns or all_fields].copy() + + with self._df_lock: + df = self._dataframe.loc[[db_name], columns or all_fields].copy() + return df.reindex(columns, axis="columns") def search(self, query: str, columns: list = None) -> pd.DataFrame: if not self.searcher: @@ -146,20 +159,22 @@ def search_database(self, query: str, database: str, columns: list = None) -> pd return self._meta_from_result(params, result, columns) def _meta_from_result(self, params: dict, result: list[int], columns: list = None) -> pd.DataFrame: - df = self._dataframe.loc[self.dataframe["id"].isin(result), columns or all_fields] - df.sort_values(by="id", inplace=True, key=lambda x: x.map({id_: i for i, id_ in enumerate(result)})) - - extra_query = " & ".join( - [ - f"`{key}`.astype('str').str.contains('{value}', False)" - for key, value in params.items() - if key in df.columns - ] - ) - if extra_query: - df = df.query(extra_query) - - return df.copy() + with self._df_lock: + df = self._dataframe.loc[self.dataframe["id"].isin(result), columns or all_fields] + df.sort_values(by="id", inplace=True, key=lambda x: x.map({id_: i for i, id_ in enumerate(result)})) + + extra_query = " & ".join( + [ + f"`{key}`.astype('str').str.contains('{value}', False)" + for key, value in params.items() + if key in df.columns + ] + ) + if extra_query: + df = df.query(extra_query) + df = df.copy() + + return df def auto_complete(self, word: str, context: Optional[set] = None, database: Optional[str] = None): if not self.searcher: diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index 75f29b3ba..19a8f4be2 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -7,8 +7,6 @@ from .fields import primary, secondary, all_types, search_engine_whitelist - - class MDSUpdater: def __init__(self, mds: MetaDataStore): self.mds = mds From dc59f1fd997eccd9f15f8d5b25554865d196992c Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 11:17:01 +0100 Subject: [PATCH 205/267] Fixing tests - set-default on ABWizard --- activity_browser/ui/widgets/wizard.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/activity_browser/ui/widgets/wizard.py b/activity_browser/ui/widgets/wizard.py index a5d6572b1..e5b599998 100644 --- a/activity_browser/ui/widgets/wizard.py +++ b/activity_browser/ui/widgets/wizard.py @@ -105,8 +105,12 @@ def setButtonLayout(self, layout: ABWizardButtonLayout): # Set the default button after a short delay to ensure the UI is updated def set_default(): - button = self.button(button_map[default_button]) - button.setFocus() + try: + button = self.button(button_map[default_button]) + button.setFocus() + except RuntimeError: + # Wizard might be closed before the timer fires + pass QtCore.QTimer.singleShot(50, set_default) From ec2ff4aba997e7e7864b11eb92e468cd8d84af00 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 11:58:26 +0100 Subject: [PATCH 206/267] Fixing tests - waiting for threads --- activity_browser/bwutils/metadata/loader.py | 20 +++++++++++-------- activity_browser/bwutils/metadata/metadata.py | 10 +++++++++- tests/conftest.py | 5 ++++- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index a7d5371cf..ef441c8bd 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -17,6 +17,7 @@ class MDSLoader: def __init__(self, mds: MetaDataStore): self.mds = mds + self.thread: threading.Thread | None = None self.connect_signals() def connect_signals(self): @@ -43,12 +44,12 @@ def load_project(self): return # start loading thread for secondary metadata - thread = SecondaryLoadThread( + self.thread = SecondaryLoadThread( databases=list(bd.databases), sqlite_db=str(sqlite3_lci_db._filepath), callback=self.secondary_load_project ) - thread.start() + self.thread.start() # load primary metadata in the main thread self.primary_load_project() @@ -76,8 +77,8 @@ def cache_load_project(self): self.primary_status = "done" self.secondary_status = "done" - thread = threading.Thread(target=self._init_searcher) - thread.start() + self.thread = threading.Thread(target=self._init_searcher) + self.thread.start() def primary_load_project(self): from bw2data.backends import sqlite3_lci_db @@ -103,7 +104,7 @@ def secondary_load_project(self, secondary_df: pd.DataFrame, sqlite_db: str): if sqlite_db != str(sqlite3_lci_db._filepath): return - assert all(secondary_df.index.isin(self.mds.dataframe.index)) + assert all(secondary_df.index.isin(self.mds.keys)) logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") left = self.mds.get_metadata(columns=primary) @@ -120,13 +121,16 @@ def load_database(self, database_name: str): self.primary_status = "loading" self.secondary_status = "loading" + if self.thread is not None and self.thread.is_alive(): + self.thread.join() + # start loading thread for secondary metadata - thread = SecondaryLoadThread( + self.thread = SecondaryLoadThread( databases=[database_name], sqlite_db=str(sqlite3_lci_db._filepath), callback=self.secondary_load_database ) - thread.start() + self.thread.start() # load primary metadata in the main thread self.primary_load_database(database_name) @@ -283,7 +287,7 @@ def run(self): self.callback(full_df, self.sqlite_db) except Exception as e: - logger.error(f"Error loading secondary metadata: {e}") + logger.error(f"Error loading secondary metadata: {e}", exc_info=True) # Call callback with empty dataframe on error self.callback(pd.DataFrame(), self.sqlite_db) diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 464d5d80f..09afc7a55 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -61,7 +61,15 @@ def dataframe(self, df: pd.DataFrame) -> None: @property def databases(self): - return set(self.dataframe.get("database", [])) + with self._df_lock: + databases = set(self._dataframe.index.get_level_values(0).unique().tolist()) + return databases + + @property + def keys(self): + with self._df_lock: + keys = set(self._dataframe.index.tolist()) + return keys def register_mutation(self, key: tuple[str, str], action: Literal["add", "update", "delete"]): if action == "add": diff --git a/tests/conftest.py b/tests/conftest.py index 8b4e2a481..6a0ed20e5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -68,5 +68,8 @@ def basic_database(qapp, main_window): while metadata.loader.secondary_status != "done": time.sleep(1) - return db + yield db + + if metadata.loader.thread.is_alive(): + metadata.loader.thread.join() From 896cd754ff57b9852ae66126715d1b97d5293cc4 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 14:22:22 +0100 Subject: [PATCH 207/267] Fixing tests --- .github/workflows/testing.yaml | 4 ++-- activity_browser/bwutils/metadata/loader.py | 1 + tests/conftest.py | 20 ++++++++++++++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index ec25e782e..a0d5bee6e 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-13, macos-latest] + os: [ubuntu-latest, windows-latest, macos-15, macos-latest] py-version: ["3.10", "3.11", "3.12"] env: QT_QPA_PLATFORM: 'offscreen' @@ -42,4 +42,4 @@ jobs: - name: Test with pytest run: | - pytest + pytest -s --no-header --no-summary -q diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index ef441c8bd..b76642706 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -122,6 +122,7 @@ def load_database(self, database_name: str): self.secondary_status = "loading" if self.thread is not None and self.thread.is_alive(): + logger.debug("Waiting for previous loading thread to finish") self.thread.join() # start loading thread for secondary metadata diff --git a/tests/conftest.py b/tests/conftest.py index 6a0ed20e5..c5a91681f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ from copy import deepcopy from importlib import reload +from loguru import logger import pandas as pd import pytest @@ -13,6 +14,9 @@ os.environ["AB_SKIP_SETTINGS_ON_STARTUP"] = "1" +# Create custom log level for testing logs +logger.level("TEST", no=25, color="", icon="🧪") + @pytest.fixture def no_exception_dialogs(monkeypatch): @@ -65,11 +69,19 @@ def basic_database(qapp, main_window): bd.calculation_setups["basic_calculation_setup"] = CALCULATION_SETUP bd.calculation_setups.flush() - while metadata.loader.secondary_status != "done": + i = 0 + while metadata.loader.secondary_status != "done" and i < 30: + logger.log("TEST", "Waiting for metadata loader to finish...") time.sleep(1) + i += 1 - yield db + while metadata.loader.thread.is_alive() and i < 30: + logger.log("TEST", "Waiting for metadata loader thread to finish...") + time.sleep(1) + i += 1 - if metadata.loader.thread.is_alive(): - metadata.loader.thread.join() + if i >= 30: + raise TimeoutError("Metadata loader did not finish in time.") + + yield db From 11effced251c56ccba4fb980459bc6f31f75897f Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 14:57:28 +0100 Subject: [PATCH 208/267] Fixing tests --- activity_browser/bwutils/metadata/loader.py | 5 +++-- activity_browser/bwutils/metadata/metadata.py | 2 +- tests/conftest.py | 16 ++++------------ 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index b76642706..9bbbe203e 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -158,10 +158,11 @@ def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): from bw2data.backends import sqlite3_lci_db if secondary_df.empty or sqlite_db != str(sqlite3_lci_db._filepath): + self.secondary_status = "done" return database = secondary_df.index[0][0] - indices = self.mds.dataframe.loc[[database]].index + indices = self.mds.get_database_metadata(database, []).index if not all(secondary_df.index.isin(indices)): logger.debug("Secondary database metadata dropping rows") @@ -179,7 +180,7 @@ def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): if hasattr(self.mds, "searcher"): search_engine_cols = list(set(all_fields) & set(search_engine_whitelist)) - df = self.mds.dataframe.loc[self.mds.dataframe["database"] == database, search_engine_cols] + df = self.mds.get_database_metadata(database, search_engine_cols) self.mds.searcher.add_identifier(df) self.secondary_status = "done" diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 09afc7a55..750bb4865 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -145,7 +145,7 @@ def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFr return pd.DataFrame(columns=columns or all_fields) with self._df_lock: - df = self._dataframe.loc[[db_name], columns or all_fields].copy() + df = self._dataframe.loc[[db_name], columns].copy() return df.reindex(columns, axis="columns") def search(self, query: str, columns: list = None) -> pd.DataFrame: diff --git a/tests/conftest.py b/tests/conftest.py index c5a91681f..5d8c39e9a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,9 +14,6 @@ os.environ["AB_SKIP_SETTINGS_ON_STARTUP"] = "1" -# Create custom log level for testing logs -logger.level("TEST", no=25, color="", icon="🧪") - @pytest.fixture def no_exception_dialogs(monkeypatch): @@ -38,7 +35,7 @@ def main_window(qtbot, monkeypatch, no_exception_dialogs): reload(metadata) reload(app.main) reload(app) - metadata.df = pd.DataFrame() + metadata.dataframe = pd.DataFrame() app.main_window.show() @@ -70,17 +67,12 @@ def basic_database(qapp, main_window): bd.calculation_setups.flush() i = 0 - while metadata.loader.secondary_status != "done" and i < 30: - logger.log("TEST", "Waiting for metadata loader to finish...") - time.sleep(1) - i += 1 - - while metadata.loader.thread.is_alive() and i < 30: - logger.log("TEST", "Waiting for metadata loader thread to finish...") + while metadata.loader.secondary_status != "done" and i < 60: + logger.warning("Waiting for metadata loader to finish...") time.sleep(1) i += 1 - if i >= 30: + if i >= 60: raise TimeoutError("Metadata loader did not finish in time.") yield db From 8696ca1bd296b327495ad9198709df76030d2c07 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 15:12:16 +0100 Subject: [PATCH 209/267] Fixing tests --- activity_browser/bwutils/metadata/metadata.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 750bb4865..461ec5dcb 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -46,16 +46,25 @@ def dataframe(self) -> pd.DataFrame: @dataframe.setter def dataframe(self, df: pd.DataFrame) -> None: - # Ensure all expected columns are present, in the correct order, and with the correct types - df = df.reindex(columns=all_fields)[all_fields].astype(all_types) + # Perform all transformations outside the lock + # Make a full copy to avoid any shared memory with the input + df = df.copy() + + # Ensure all expected columns are present, in the correct order + df = df.reindex(columns=all_fields)[all_fields] + + # Apply types carefully - avoid in-place modifications + for col, col_type in all_types.items(): + if col in df.columns: + df[col] = df[col].astype(col_type) # No NaN values in object columns, use None instead for col, col_type in all_types.items(): - if col_type != object: + if col_type != object or col not in df.columns: continue df[col] = df[col].where(df[col].notnull(), None) - # Set the internal dataframe + # Set the internal dataframe under lock with self._df_lock: self._dataframe = df From bbcb0298ce43604f4c35c9cf00cccb11b8d6e156 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 15:12:20 +0100 Subject: [PATCH 210/267] Fixing tests --- activity_browser/bwutils/metadata/loader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 9bbbe203e..159429923 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -275,6 +275,7 @@ def __init__(self, databases: list[str], sqlite_db: str, callback: Callable): def run(self): """Execute the loading in a background thread.""" try: + logger.debug("Starting secondary metadata load with multiprocessing Pool") with Pool() as pool: args = [(self.sqlite_db, db, secondary) for db in self.databases] results = pool.starmap(load, args) From e92588c024bc1123d350db857be9fb181b1bc967 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 15:29:06 +0100 Subject: [PATCH 211/267] Fixing tests --- activity_browser/bwutils/metadata/loader.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 159429923..9cbe438f3 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -181,6 +181,8 @@ def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): if hasattr(self.mds, "searcher"): search_engine_cols = list(set(all_fields) & set(search_engine_whitelist)) df = self.mds.get_database_metadata(database, search_engine_cols) + for col in df.select_dtypes(include=['category']).columns: + df[col] = df[col].astype(object) self.mds.searcher.add_identifier(df) self.secondary_status = "done" From ea8e45cc1d03046e59093dacf6a14853682273fd Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 15:39:38 +0100 Subject: [PATCH 212/267] Fixing tests --- activity_browser/bwutils/searchengine/base.py | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index aa6acce57..d16763a4d 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -130,10 +130,7 @@ def update_dict(update_me: dict, new: dict) -> dict: self.q_gram_to_word = update_dict(self.q_gram_to_word, q2w) size_new = len(self.df) size_dif = size_new - size_old - size_msg = (f"{size_dif} changed items at {int(round(size_dif/(time() - t), 0))} items/sec " - f"({size_new} items ({self.size_of_index()}) currently)") if size_dif > 1 \ - else f"1 changed item ({size_new} items ({self.size_of_index()}) currently)" - logger.debug(f"Search index updated in {time() - t:.2f} seconds for {size_msg}.") + logger.debug(f"Search index updated in {time() - t:.2f} seconds.") def clean_text(self, text: str): """Clean a string so it doesn't contain weird characters or multiple spaces etc.""" @@ -224,20 +221,6 @@ def word_in_index(self, word: str) -> bool: f"Given word '{word}' must not contain spaces.") return word in self.word_to_identifier.keys() - def size_of_index(self): - """return the size of the search index in MB or GB.""" - s_df = sys.getsizeof(self.df) - s_i2w = sys.getsizeof(self.identifier_to_word) - s_w2i = sys.getsizeof(self.word_to_identifier) - s_w2q = sys.getsizeof(self.word_to_q_grams) - s_q2w = sys.getsizeof(self.q_gram_to_word) - size_bytes = s_df + s_i2w + s_w2i + s_w2q + s_q2w - - if size_bytes < 1024 ** 3: - return f"{size_bytes / (1024 ** 2):.1f} MB" - else: - return f"{size_bytes / (1024 ** 3):.2f} GB" - # +++ Changes to searchable data def add_identifier(self, data: pd.DataFrame) -> None: @@ -332,7 +315,7 @@ def remove_identifier(self, identifier, logging=True) -> None: if logging: logger.debug(f"Search index updated in {time() - t:.2f} seconds " - f"for 1 removed item ({len(self.df)} items ({self.size_of_index()}) currently).") + f"for 1 removed item ({len(self.df)}.") def change_identifier(self, identifier, data: pd.DataFrame) -> None: """Change this identifier. From 225b13e4ad78871f25dc208293be27fcfd0f5a76 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 15:51:30 +0100 Subject: [PATCH 213/267] Fixing tests --- activity_browser/bwutils/metadata/metadata.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 461ec5dcb..90570626e 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -41,14 +41,14 @@ def __init__(self): @property def dataframe(self) -> pd.DataFrame: with self._df_lock: - copy = self._dataframe.copy() + copy = self._dataframe.copy(deep=True) return copy @dataframe.setter def dataframe(self, df: pd.DataFrame) -> None: # Perform all transformations outside the lock # Make a full copy to avoid any shared memory with the input - df = df.copy() + df = df.copy(deep=True) # Ensure all expected columns are present, in the correct order df = df.reindex(columns=all_fields)[all_fields] @@ -129,7 +129,7 @@ def match(self, **kwargs: dict[str, str]) -> pd.DataFrame: f"`{key}`.astype('str') == {str(value)!r}" if not pd.isna(value) else f"`{key}`.isnull()" for key, value in kwargs.items() ]) - ).copy() + ).copy(deep=True) return df @@ -144,7 +144,7 @@ def get_metadata(self, keys: list = None, columns: list = None) -> pd.DataFrame: columns = columns if columns is not None else all_fields with self._df_lock: - df = self._dataframe.loc[pd.IndexSlice[keys], :].copy() + df = self._dataframe.loc[pd.IndexSlice[keys], :].copy(deep=True) return df.reindex(columns, axis="columns") def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFrame: @@ -154,7 +154,7 @@ def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFr return pd.DataFrame(columns=columns or all_fields) with self._df_lock: - df = self._dataframe.loc[[db_name], columns].copy() + df = self._dataframe.loc[[db_name], columns].copy(deep=True) return df.reindex(columns, axis="columns") def search(self, query: str, columns: list = None) -> pd.DataFrame: @@ -189,7 +189,7 @@ def _meta_from_result(self, params: dict, result: list[int], columns: list = Non ) if extra_query: df = df.query(extra_query) - df = df.copy() + df = df.copy(deep=True) return df From 42182d9ee5853ec7bdd3bc1be1c886d5e198e027 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 16:01:32 +0100 Subject: [PATCH 214/267] Fixing tests --- activity_browser/bwutils/metadata/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 9cbe438f3..0c64b7ac2 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -172,7 +172,7 @@ def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): df = self.mds.dataframe self._fix_categories(secondary_df, df) - df.update(secondary_df) + df = secondary_df.combine_first(df) self.mds.dataframe = df for idx in secondary_df.index: From 5943f6f50c819e9eeb34336fc63b688fb876eaa0 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 16:30:17 +0100 Subject: [PATCH 215/267] Fixing tests --- activity_browser/bwutils/metadata/fields.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/activity_browser/bwutils/metadata/fields.py b/activity_browser/bwutils/metadata/fields.py index 63d12bff3..8a7d6458a 100644 --- a/activity_browser/bwutils/metadata/fields.py +++ b/activity_browser/bwutils/metadata/fields.py @@ -2,19 +2,19 @@ "key": object, "id": "Int64", "code": str, - "database": "category", - "location": "category", + "database": object, + "location": object, "name": str, "product": object, - "type": "category", + "type": object, } secondary_types = { "synonyms": object, - "unit": "category", - "CAS number": "category", + "unit": object, + "CAS number": object, "categories": object, "processor": object, - "allocation": "category", + "allocation": object, "allocation_factor": float, "properties": object, } From 9805a26ff1f3b095b6b3380ab249225cfebab18e Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 17:42:50 +0100 Subject: [PATCH 216/267] Fixing tests --- activity_browser/bwutils/metadata/loader.py | 5 +++++ tests/conftest.py | 1 + 2 files changed, 6 insertions(+) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 0c64b7ac2..63e8dc6e3 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -1,6 +1,7 @@ import sqlite3 import pickle import threading +import os from multiprocessing import Pool from loguru import logger from typing import Literal, Callable @@ -202,6 +203,10 @@ def _fix_categories(df: pd.DataFrame, mds_df: pd.DataFrame): def _init_searcher(self): from .searcher import MDSSearcher + if os.environ.get("AB_NO_SEARCHER"): + logger.debug("Skipping searcher initialization due to AB_NO_SEARCHER environment variable") + return + if hasattr(self.mds, 'searcher') and self.mds.searcher is not None: old_searcher = self.mds.searcher self.mds.searcher = None diff --git a/tests/conftest.py b/tests/conftest.py index 5d8c39e9a..02262a628 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ from bw2data.tests import bw2test os.environ["AB_SKIP_SETTINGS_ON_STARTUP"] = "1" +os.environ["AB_NO_SEARCHER"] = "1" @pytest.fixture From f25ef8bbb3bd049d48e0b79631b99d4b46cd4e61 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 9 Dec 2025 17:44:31 +0100 Subject: [PATCH 217/267] Fixing tests --- activity_browser/bwutils/metadata/loader.py | 2 +- activity_browser/bwutils/metadata/updater.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 63e8dc6e3..68cc423a0 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -179,7 +179,7 @@ def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): for idx in secondary_df.index: self.mds.register_mutation(idx, "update") - if hasattr(self.mds, "searcher"): + if hasattr(self.mds, "searcher") and self.mds.searcher is not None: search_engine_cols = list(set(all_fields) & set(search_engine_whitelist)) df = self.mds.get_database_metadata(database, search_engine_cols) for col in df.select_dtypes(include=['category']).columns: diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index 19a8f4be2..46268c702 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -82,7 +82,7 @@ def modify_node(self, ds: pd.Series): self.mds.dataframe = df self.mds.register_mutation(ds.key, "update") - if not hasattr(self.mds, "searcher"): + if not hasattr(self.mds, "searcher") and self.mds.searcher is not None: return search_engine_cols = list(set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns @@ -98,7 +98,7 @@ def add_node(self, ds: pd.Series): self.mds.dataframe = df self.mds.register_mutation(ds.key, "add") - if not hasattr(self.mds, "searcher"): + if not hasattr(self.mds, "searcher") and self.mds.searcher is not None: return search_engine_cols = list(set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns @@ -109,7 +109,7 @@ def delete_node(self, ds: pd.Series): self.mds.dataframe = self.mds.dataframe.drop(ds.key) self.mds.register_mutation(ds.key, "delete") - if not hasattr(self.mds, "searcher"): + if not hasattr(self.mds, "searcher") and self.mds.searcher is not None: return id = ds["id"] @@ -131,7 +131,7 @@ def delete_database(self, db_name: str): self.mds.dataframe = self.mds.dataframe.drop(db_name, level=0) - if not hasattr(self.mds, "searcher"): + if not hasattr(self.mds, "searcher") and self.mds.searcher is not None: return for id in ids: From 9dbc23fc8a916a2de53a3b9ac97f53b30ebf7b3a Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 09:06:45 +0100 Subject: [PATCH 218/267] Application documentation --- activity_browser/README.md | 55 +++ activity_browser/app/README.md | 70 ++++ activity_browser/app/actions/README.md | 116 +++++++ activity_browser/app/dialogs/README.md | 120 +++++++ activity_browser/app/pages/README.md | 126 +++++++ activity_browser/app/panes/README.md | 121 +++++++ activity_browser/bwutils/README.md | 59 ++++ .../ecoinvent_biosphere_versions/README.md | 138 ++++++++ activity_browser/bwutils/io/README.md | 116 +++++++ activity_browser/bwutils/metadata/README.md | 141 ++++++++ .../bwutils/searchengine/README.md | 179 ++++++++++ .../bwutils/superstructure/README.md | 179 ++++++++++ activity_browser/mod/README.md | 58 ++++ activity_browser/mod/bw2analyzer/README.md | 185 ++++++++++ activity_browser/static/README.md | 63 ++++ activity_browser/static/css/README.md | 245 +++++++++++++ activity_browser/static/icons/README.md | 214 ++++++++++++ activity_browser/ui/README.md | 89 +++++ activity_browser/ui/core/README.md | 178 ++++++++++ activity_browser/ui/delegates/README.md | 222 ++++++++++++ activity_browser/ui/dialogs/README.md | 269 +++++++++++++++ activity_browser/ui/web/README.md | 310 +++++++++++++++++ activity_browser/ui/widgets/README.md | 202 +++++++++++ activity_browser/ui/wizards/README.md | 295 ++++++++++++++++ activity_browser_beta/README.md | 154 +++++++++ docs/README.md | 205 +++++++++++ recipe/README.md | 217 ++++++++++++ tests/README.md | 322 ++++++++++++++++++ 28 files changed, 4648 insertions(+) create mode 100644 activity_browser/README.md create mode 100644 activity_browser/app/README.md create mode 100644 activity_browser/app/actions/README.md create mode 100644 activity_browser/app/dialogs/README.md create mode 100644 activity_browser/app/pages/README.md create mode 100644 activity_browser/app/panes/README.md create mode 100644 activity_browser/bwutils/README.md create mode 100644 activity_browser/bwutils/ecoinvent_biosphere_versions/README.md create mode 100644 activity_browser/bwutils/io/README.md create mode 100644 activity_browser/bwutils/metadata/README.md create mode 100644 activity_browser/bwutils/searchengine/README.md create mode 100644 activity_browser/bwutils/superstructure/README.md create mode 100644 activity_browser/mod/README.md create mode 100644 activity_browser/mod/bw2analyzer/README.md create mode 100644 activity_browser/static/README.md create mode 100644 activity_browser/static/css/README.md create mode 100644 activity_browser/static/icons/README.md create mode 100644 activity_browser/ui/README.md create mode 100644 activity_browser/ui/core/README.md create mode 100644 activity_browser/ui/delegates/README.md create mode 100644 activity_browser/ui/dialogs/README.md create mode 100644 activity_browser/ui/web/README.md create mode 100644 activity_browser/ui/widgets/README.md create mode 100644 activity_browser/ui/wizards/README.md create mode 100644 activity_browser_beta/README.md create mode 100644 docs/README.md create mode 100644 recipe/README.md create mode 100644 tests/README.md diff --git a/activity_browser/README.md b/activity_browser/README.md new file mode 100644 index 000000000..6f68b4f15 --- /dev/null +++ b/activity_browser/README.md @@ -0,0 +1,55 @@ +# activity_browser + +This is the main package directory for the Activity Browser application. + +## Overview + +Activity Browser is a Qt-based desktop application that provides a GUI front-end for Brightway2, enabling users to perform Life Cycle Assessment (LCA) calculations with an intuitive interface. + +## Directory Structure + +- **`app/`** - Main application logic, including the main window, actions, dialogs, pages, and panes +- **`bwutils/`** - Utility functions and helpers that extend Brightway2 functionality +- **`mod/`** - Monkey-patches and modifications to third-party libraries (bw2analyzer, bw2io, etc.) +- **`static/`** - Static resources including HTML templates, CSS, icons, fonts, and JavaScript files +- **`ui/`** - Core UI components including widgets, dialogs, wizards, and web views +- **`docs/`** - Internal documentation and wiki files + +## Key Files + +- **`__init__.py`** - Package initialization with PySide6/typing compatibility patches +- **`__main__.py`** - Entry point for the application (`run_activity_browser` function) +- **`info.py`** - Version and application metadata +- **`settings.py`** - Settings management using platformdirs for persistent user/project settings + +## Entry Points + +The application can be started in multiple ways: +- Console script: `activity-browser` (installed via setuptools) +- Direct module execution: `python -m activity_browser` +- Script execution: `python run-activity-browser.py` + +All entry points lead to `activity_browser.__main__:run_activity_browser`. + +## Architecture + +The application follows an MVC-like pattern with: +- **Global signals** (`activity_browser.app.signals`) - Event bus for cross-component communication +- **Settings persistence** (`ab_settings`, `project_settings`) - JSON-based configuration storage +- **Deferred imports** - Heavy modules are loaded in background threads during startup +- **Actions pattern** - UI operations encapsulated in `app/actions/` with a base class pattern + +## Dependencies + +Main dependencies include: +- **PySide6** (via qtpy) - Qt bindings for the GUI +- **Brightway2** ecosystem (bw2data, bw2calc, bw2analyzer, bw2io) - LCA calculation engine +- **platformdirs** - Cross-platform settings directory management +- **loguru** - Logging framework + +## Development Notes + +- Avoid top-level imports of heavy modules (PySide6, bw2data) to keep tests fast +- Use project signals for cross-component communication instead of direct function calls +- Settings are persisted per-user via platformdirs; use the singleton instances +- Global shortcuts are registered via `@application.global_shortcut` decorator diff --git a/activity_browser/app/README.md b/activity_browser/app/README.md new file mode 100644 index 000000000..785b676f7 --- /dev/null +++ b/activity_browser/app/README.md @@ -0,0 +1,70 @@ +# app + +Main application module containing the core logic and structure of the Activity Browser. + +## Overview + +This module orchestrates the main application components including the main window, menu bar, signal handling, and various UI elements organized into actions, dialogs, pages, and panes. + +## Directory Structure + +- **`actions/`** - Encapsulated UI operations and commands (activity, database, calculation setup, etc.) +- **`dialogs/`** - Dialog windows for user interactions +- **`pages/`** - Main content pages displayed in the application (activity details, calculations, parameters, etc.) +- **`panes/`** - Dock-able panes that can be arranged around the main content area + +## Key Files + +- **`__init__.py`** - Module initialization creating singleton instances: + - `application` - ABApplication instance + - `metadata` - MetaDataStore instance + - `settings` - Settings instance + - `signals` - ABSignals instance (event bus) + - `main_window` - MainWindow instance + +- **`main_window.py`** - MainWindow class that holds the central widget and dock panes +- **`menu_bar.py`** - Application menu bar with File, Edit, View, Tools, Help menus +- **`signalling.py`** - ABSignals class that bridges bw2data signals to Qt signals + +## Architecture + +The app module creates and wires together the core application components: + +1. **Application** (`ABApplication`) - Qt application instance with global shortcut management +2. **Signals** (`ABSignals`) - Project-wide event bus for cross-component communication +3. **Main Window** (`MainWindow`) - Main application window with pages and panes +4. **Actions** - Command pattern implementation for menu items and toolbar actions +5. **Pages** - Content area widgets for different application views +6. **Panes** - Dock-able side panels + +## Signal Flow + +The signals instance serves as the central event bus: +- Bridges Brightway2 data events to Qt signals +- Enables loose coupling between UI components +- Used throughout the application for state updates + +## Usage Pattern + +Components should access the application objects via: + +```python +from activity_browser import app + +# Access global instances +app.application # ABApplication instance +app.signals # Event bus +app.settings # Settings manager +app.metadata # Metadata store +app.main_window # Main window +``` + +## Actions Pattern + +Actions encapsulate user commands and are defined in the `actions/` subdirectory. Each action: +- Inherits from `ABAction` base class +- Defines icon, text, tooltip +- Implements a `run()` static method +- Can be converted to QAction or QPushButton + +See `actions/base.py` for the action framework. diff --git a/activity_browser/app/actions/README.md b/activity_browser/app/actions/README.md new file mode 100644 index 000000000..a6f513443 --- /dev/null +++ b/activity_browser/app/actions/README.md @@ -0,0 +1,116 @@ +# actions + +Encapsulated UI operations and commands following the action pattern. + +## Overview + +This directory contains all user-triggered actions in Activity Browser. Each action represents a discrete operation that can be invoked from menus, toolbars, or keyboard shortcuts. + +## Directory Structure + +- **`activity/`** - Actions related to activities (create, edit, delete, duplicate, etc.) +- **`calculation_setup/`** - Actions for calculation setup management +- **`database/`** - Database operations (import, export, delete, backup, etc.) +- **`exchange/`** - Actions for exchanges between activities +- **`method/`** - Impact assessment method management +- **`parameter/`** - Parameter management actions +- **`project/`** - Project-level operations +- **`tools/`** - Various tools and utilities accessible via actions + +## Key Files + +- **`base.py`** - `ABAction` base class that all actions inherit from +- **`metadatastore_open.py`** - Action to open the metadata store dialog +- **`migrations_install.py`** - Database migration actions +- **`node_select_open.py`** - Node selection dialog action +- **`pyside_upgrade.py`** - PySide upgrade helper action +- **`save_parameters_to_excel.py`** - Export parameters to Excel +- **`settings_wizard_open.py`** - Settings wizard dialog action + +## Action Pattern + +All actions follow a consistent pattern defined in `base.py`: + +```python +class MyAction(ABAction): + icon = QtGui.QIcon(...) # Action icon + text = "My Action" # Display text + tooltip = "Description" # Tooltip text + + @staticmethod + def run(*args, **kwargs): + # Action implementation + pass +``` + +### Key Features: + +1. **Declarative** - Icon, text, and tooltip defined as class attributes +2. **Callable arguments** - Arguments can be functions (evaluated at runtime) +3. **Qt integration** - Can be converted to QAction or QPushButton +4. **Exception handling** - Optional decorator for error dialogs +5. **Flexible invocation** - Triggered from menus, buttons, shortcuts + +## Usage + +Actions can be used in multiple ways: + +### As Menu Items +```python +action = MyAction.get_QAction(parent=menu) +menu.addAction(action) +``` + +### As Buttons +```python +button = MyAction.get_QButton() +layout.addWidget(button) +``` + +### Direct Invocation +```python +MyAction.run(arg1, arg2) +``` + +## Subdirectory Organization + +Each subdirectory groups related actions: + +- **`activity/`** - Activity CRUD operations, navigation, graph viewing +- **`calculation_setup/`** - Setup creation, modification, calculation execution +- **`database/`** - Import from various sources, export, deletion, backup/restore +- **`exchange/`** - Add/remove/modify exchanges, uncertainty, formulas +- **`method/`** - Method import, export, modification, deletion +- **`parameter/`** - Parameter creation, editing, scenarios +- **`project/`** - Project creation, switching, deletion, settings +- **`tools/`** - Monte Carlo, sensitivity analysis, superstructure tools + +## Development Guidelines + +When adding new actions: + +1. Inherit from `ABAction` base class +2. Define icon, text, and tooltip class attributes +3. Implement the `run()` static method with the action logic +4. Place in the appropriate subdirectory by functionality +5. Use `@exception_dialogs` decorator for user-facing error handling +6. Import and register in the parent `__init__.py` +7. Connect to global signals when state changes + +## Signal Integration + +Actions should emit signals when they modify application state: + +```python +from activity_browser import app + +class MyAction(ABAction): + @staticmethod + def run(): + # Perform operation + ... + # Emit signal + app.signals.database_changed.emit() +``` + +This ensures other components can react to state changes without tight coupling. diff --git a/activity_browser/app/dialogs/README.md b/activity_browser/app/dialogs/README.md new file mode 100644 index 000000000..639148e6b --- /dev/null +++ b/activity_browser/app/dialogs/README.md @@ -0,0 +1,120 @@ +# dialogs + +Dialog windows for user interactions throughout Activity Browser. + +## Overview + +This directory contains modal and non-modal dialog windows used for various user interactions such as data entry, configuration, selection, and information display. + +## Purpose + +Dialogs provide focused interfaces for: +- User input and data entry +- Configuration and settings +- Selection of items (activities, methods, databases) +- Information display and confirmations +- Multi-step workflows (see also `ui/wizards/`) + +## Common Dialog Types + +### Input Dialogs +- Text input fields +- Numeric value entry +- Date/time selection +- Multi-line text editing + +### Selection Dialogs +- List/tree item selection +- Database/activity pickers +- Method selection +- File/directory choosers + +### Configuration Dialogs +- Settings editors +- Preference panels +- Option configuration + +### Information Dialogs +- Progress indicators +- Status messages +- Warnings and errors +- About/help information + +## Design Guidelines + +Dialogs in Activity Browser should: + +1. **Be modal when appropriate** - Block parent window for critical decisions +2. **Provide clear actions** - OK/Cancel, Accept/Reject, or custom actions +3. **Validate input** - Check data before accepting +4. **Give feedback** - Show errors, warnings, progress +5. **Be responsive** - Use threading for long operations +6. **Follow Qt conventions** - Inherit from QDialog, use standard buttons + +## Usage Pattern + +```python +from qtpy.QtWidgets import QDialog, QDialogButtonBox + +class MyDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + # Build dialog UI + pass + + def accept(self): + # Validate and process input + if self.validate(): + super().accept() +``` + +## Integration with Actions + +Dialogs are typically opened via actions: + +```python +from activity_browser.app.actions.base import ABAction + +class OpenMyDialog(ABAction): + @staticmethod + def run(): + dialog = MyDialog() + if dialog.exec_() == QDialog.Accepted: + # Process result + pass +``` + +## Threading Considerations + +Long-running operations in dialogs should use worker threads: + +```python +from activity_browser.ui.core.threading import ABThread + +class MyDialog(QDialog): + def perform_long_operation(self): + worker = ABThread(self.expensive_task) + worker.finished.connect(self.on_complete) + worker.start() +``` + +## Signal Emission + +Dialogs should emit signals to notify the application of changes: + +```python +from activity_browser import app + +class MyDialog(QDialog): + def accept(self): + # Save changes + self.save_data() + # Notify application + app.signals.data_changed.emit() + super().accept() +``` + +This ensures the rest of the application can react to changes made in dialogs without tight coupling. diff --git a/activity_browser/app/pages/README.md b/activity_browser/app/pages/README.md new file mode 100644 index 000000000..8802c4c21 --- /dev/null +++ b/activity_browser/app/pages/README.md @@ -0,0 +1,126 @@ +# pages + +Main content pages displayed in the Activity Browser application. + +## Overview + +This directory contains the primary content pages that users interact with in Activity Browser. Each page represents a major functional area and is displayed in the central widget of the main window. + +## Directory Structure + +- **`activity_details/`** - Activity information display and editing +- **`calculation_setup/`** - Calculation setup configuration and management +- **`impact_category_details/`** - Impact category information and visualization +- **`lca_results/`** - LCA calculation results display and analysis +- **`parameters/`** - Parameter management and scenario configuration +- **`settings/`** - Application settings and preferences + +## Key Files + +- **`welcome.py`** - Welcome page shown when no project is open or on first launch +- **`metadatastore.py`** - Metadata management page + +## Page Architecture + +Pages inherit from `AbstractPage` (in `ui/widgets/abstract_page.py`) which provides: +- Consistent layout structure +- Signal connections +- Toolbar integration +- State management + +## Page Lifecycle + +1. **Creation** - Page is instantiated and added to the main window +2. **Display** - User navigates to the page (shown in central widget) +3. **Updates** - Page responds to signals and refreshes data +4. **Interaction** - User performs actions within the page +5. **Persistence** - Page state may be saved when switching away + +## Common Page Features + +### Toolbars +Most pages include a toolbar with actions: +```python +self.toolbar = QToolBar() +self.toolbar.addAction(MyAction.get_QAction()) +``` + +### Data Display +Pages typically contain: +- Tables showing lists of items +- Tree views for hierarchical data +- Charts and plots for visualizations +- Forms for data entry + +### Signal Handling +Pages connect to global signals: +```python +from activity_browser import app + +app.signals.database_changed.connect(self.update_content) +``` + +## Page Navigation + +Users navigate between pages via: +- Menu bar (View menu) +- Toolbar buttons +- Context menus +- Actions triggered by events (e.g., double-click activity → show details) + +## Development Guidelines + +When creating new pages: + +1. **Inherit from AbstractPage** - Use the base class for consistency +2. **Set page title** - Provide a clear, descriptive title +3. **Create toolbar** - Add relevant actions for the page +4. **Connect signals** - Listen for relevant application events +5. **Handle updates** - Refresh data when underlying state changes +6. **Manage state** - Save/restore page state when appropriate +7. **Use threading** - Long operations should not block the UI + +## Subdirectory Details + +### `activity_details/` +Display and edit activity information including: +- Basic activity data (name, location, unit, etc.) +- Exchanges (inputs/outputs) +- Parameters and formulas +- Metadata and classifications + +### `calculation_setup/` +Configure and manage calculation setups: +- Reference flows (functional units) +- Impact assessment methods +- Scenario selections +- Calculation execution + +### `impact_category_details/` +Show impact category information: +- Characterization factors +- Method hierarchy +- Method metadata + +### `lca_results/` +Display LCA calculation results: +- Impact scores +- Contribution analyses +- Sankey diagrams +- Graph visualizations +- Export options + +### `parameters/` +Manage parameters and scenarios: +- Project parameters +- Database parameters +- Activity parameters +- Parameter formulas +- Scenario management + +### `settings/` +Application configuration: +- General preferences +- Project settings +- Plugin configuration +- Import/export settings diff --git a/activity_browser/app/panes/README.md b/activity_browser/app/panes/README.md new file mode 100644 index 000000000..35c959916 --- /dev/null +++ b/activity_browser/app/panes/README.md @@ -0,0 +1,121 @@ +# panes + +Dock-able side panels that can be arranged around the main content area. + +## Overview + +This directory contains pane widgets that can be docked to the edges of the main window or floated as separate windows. Panes provide quick access to navigation, information, and tools while working with the main content pages. + +## Purpose + +Panes offer: +- **Quick navigation** - Browse databases, activities, methods +- **Contextual information** - Show details about selected items +- **Tool access** - Quick access to common tools and operations +- **Workspace customization** - Users can arrange panes to suit their workflow + +## Pane Architecture + +Panes inherit from `AbstractPane` (in `ui/widgets/abstract_pane.py`) which provides: +- Dock widget functionality +- Consistent styling +- Signal connections +- State persistence (dock position, visibility) + +## Common Pane Types + +### Navigation Panes +- **Database browser** - Tree view of available databases +- **Activity browser** - Search and browse activities +- **Method browser** - Browse impact assessment methods +- **Project browser** - List of Brightway projects + +### Information Panes +- **Details panel** - Show details of selected items +- **Properties** - Display item properties and metadata +- **History** - Recent actions or visited items + +### Tool Panes +- **Quick calculations** - Run simple calculations +- **Search** - Global search interface +- **Console** - Python console for advanced users + +## Pane Features + +### Docking Behavior +Panes can be: +- Docked to window edges (left, right, top, bottom) +- Stacked with other panes (tabbed) +- Floated as separate windows +- Resized by dragging dividers +- Hidden/shown via View menu + +### State Persistence +Pane positions and visibility are saved between sessions: +- Dock area and position +- Floating window geometry +- Visibility state +- Tab order when stacked + +## Usage Pattern + +```python +from activity_browser.ui.widgets import AbstractPane + +class MyPane(AbstractPane): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def setup_ui(self): + # Build pane content + pass +``` + +## Integration with Main Window + +Panes are added to the main window as dock widgets: + +```python +from activity_browser import app + +pane = MyPane() +app.main_window.addDockWidget(Qt.LeftDockWidgetArea, pane) +``` + +## Signal Communication + +Panes communicate with other components via signals: + +```python +from activity_browser import app + +class MyPane(AbstractPane): + def on_item_selected(self, item): + # Emit signal for other components + app.signals.item_selected.emit(item) +``` + +## Development Guidelines + +When creating new panes: + +1. **Inherit from AbstractPane** - Use the base class for consistency +2. **Set pane title** - Provide a clear, descriptive title +3. **Keep focused** - Each pane should have a single, clear purpose +4. **Connect signals** - Listen for and emit relevant signals +5. **Handle updates** - Refresh when underlying data changes +6. **Support search/filter** - Allow users to find items quickly +7. **Provide context menus** - Right-click actions for items +8. **Make it closeable** - Users should be able to hide panes +9. **Support keyboard navigation** - Enable keyboard shortcuts + +## Visibility Control + +Panes can be shown/hidden via: +- View menu (one menu item per pane) +- Toolbar buttons +- Keyboard shortcuts +- Context menu on title bar + +The main window tracks pane visibility and provides a centralized way to manage them. diff --git a/activity_browser/bwutils/README.md b/activity_browser/bwutils/README.md new file mode 100644 index 000000000..6410b6955 --- /dev/null +++ b/activity_browser/bwutils/README.md @@ -0,0 +1,59 @@ +# bwutils + +Utility functions and helpers that extend and build upon Brightway2 functionality. + +## Overview + +This module provides a collection of generic methods and utilities that wrap and extend Brightway2 operations. These utilities are used throughout the Activity Browser to avoid code duplication and provide consistent interfaces to Brightway2 functionality. + +## Directory Structure + +- **`ecoinvent_biosphere_versions/`** - Ecoinvent biosphere database version mappings +- **`io/`** - Import/export operations for data interchange +- **`metadata/`** - Metadata management for activities and databases +- **`searchengine/`** - Search functionality for activities and exchanges +- **`superstructure/`** - Superstructure scenario analysis tools + +## Key Files + +- **`commontasks.py`** - Common Brightway2 operations (database management, activity operations) +- **`errors.py`** - Custom exception classes for Brightway2 operations +- **`exporters.py`** - Export functionality for databases and activities +- **`importers.py`** - Import functionality for various LCA data formats +- **`filesystem.py`** - File system operations for Brightway2 data directories +- **`manager.py`** - High-level management of Brightway2 projects and databases +- **`montecarlo.py`** - Monte Carlo simulation helpers +- **`multilca.py`** - Multi-functional LCA calculation utilities +- **`pedigree.py`** - Pedigree matrix uncertainty handling +- **`sensitivity_analysis.py`** - Global sensitivity analysis tools +- **`settings.py`** - Settings specific to bwutils operations +- **`strategies.py`** - Import strategies and data transformation functions +- **`uncertainty.py`** - Uncertainty analysis utilities +- **`utils.py`** - General utility functions + +## Purpose + +The bwutils module serves as an abstraction layer between the Activity Browser UI and Brightway2, providing: + +1. **Consistency** - Standardized interfaces for common operations +2. **Error Handling** - Graceful handling of Brightway2 exceptions +3. **Extensions** - Additional functionality not provided by Brightway2 +4. **Integration** - Bridging between Qt UI and Brightway2 data structures + +## Usage Pattern + +Import utilities as needed throughout the application: + +```python +from activity_browser.bwutils import commontasks +from activity_browser.bwutils.errors import ABError +from activity_browser.bwutils.manager import ABManager +``` + +## Design Principle + +Keep utilities generic and reusable. These functions should: +- Work with Brightway2 data structures +- Be independent of UI components +- Be testable without requiring a GUI +- Emit signals when state changes (via `activity_browser.app.signals`) diff --git a/activity_browser/bwutils/ecoinvent_biosphere_versions/README.md b/activity_browser/bwutils/ecoinvent_biosphere_versions/README.md new file mode 100644 index 000000000..0370ec8b7 --- /dev/null +++ b/activity_browser/bwutils/ecoinvent_biosphere_versions/README.md @@ -0,0 +1,138 @@ +# ecoinvent_biosphere_versions + +Ecoinvent biosphere database version mappings and compatibility information. + +## Overview + +This directory manages compatibility between different versions of ecoinvent databases and their corresponding biosphere flows. It ensures that biosphere flows are correctly linked when importing ecoinvent databases. + +## Key Files + +- **`compatible_ei_versions.txt`** - List of compatible ecoinvent versions +- **`ecospold2biosphereimporter.py`** - Custom importer for ecospold2 biosphere flows +- **`legacy_biosphere/`** - Legacy biosphere flow definitions for older ecoinvent versions + +## Purpose + +Ecoinvent databases come in different versions (e.g., 3.6, 3.7, 3.8, 3.9), and each version may have: +- Different biosphere flow definitions +- Updated flow names or properties +- New or deprecated flows +- Different CAS numbers or UUIDs + +This module ensures: +- **Correct linking** - Activities link to the right biosphere flows +- **Version compatibility** - Handle differences between ecoinvent versions +- **Migration support** - Update flows when upgrading ecoinvent versions +- **Legacy support** - Work with older databases + +## Compatible Versions + +The `compatible_ei_versions.txt` file lists ecoinvent versions that Activity Browser supports. Typically includes: +- ecoinvent 3.5 +- ecoinvent 3.6 +- ecoinvent 3.7 +- ecoinvent 3.8 +- ecoinvent 3.9 +- ecoinvent 3.10 + +## Biosphere Flow Linking + +When importing an ecoinvent database: + +1. **Detect version** - Identify ecoinvent version from metadata +2. **Load biosphere** - Use appropriate biosphere flow set +3. **Link flows** - Match elementary flows to biosphere database +4. **Handle mismatches** - Resolve or report linking issues + +## ecospold2biosphereimporter.py + +Custom importer that: +- Extends brightway2-io's ecospold2 importer +- Handles version-specific biosphere flows +- Applies migration strategies +- Fixes known issues in ecoinvent data + +## Legacy Biosphere + +The `legacy_biosphere/` directory contains: +- Flow definitions from older ecoinvent versions +- Migration mappings between versions +- Deprecated flow information +- Compatibility patches + +## Usage Pattern + +Typically used automatically during import: + +```python +from activity_browser.bwutils.importers import import_ecoinvent + +# Import will automatically handle biosphere version +import_ecoinvent( + filepath="ecoinvent_38_cutoff.ecospold", + database_name="ecoinvent 3.8" +) +``` + +## Version Detection + +Ecoinvent version is detected from: +- File metadata in ecospold files +- Database description field +- Version field in activity metadata +- Directory/file naming patterns + +## Handling Version Mismatches + +When biosphere versions don't match: + +1. **Automatic migration** - Update flow references +2. **Manual linking** - User selects correct flows +3. **Warning messages** - Inform user of issues +4. **Fallback matching** - Use fuzzy matching as last resort + +## Development Guidelines + +When adding support for new ecoinvent versions: + +1. **Update compatible versions** - Add to `compatible_ei_versions.txt` +2. **Test import** - Verify all flows link correctly +3. **Document changes** - Note any flow changes from previous version +4. **Add migrations** - If flows changed, add migration strategies +5. **Update tests** - Add test for new version + +## Common Issues + +### Unlinked Flows +If flows don't link: +- Check ecoinvent version detection +- Verify biosphere database version +- Review flow names for changes +- Check for typos or encoding issues + +### Wrong Flow Versions +If using wrong flow set: +- Verify version detection logic +- Check metadata parsing +- Update version mapping + +### Missing Flows +If flows are missing: +- Check if flows were added in newer ecoinvent version +- Verify biosphere database is up-to-date +- Add manual definitions if needed + +## Maintenance + +Keep up-to-date with: +- New ecoinvent releases +- Biosphere flow changes +- Brightway2 updates +- User-reported issues + +## Resources + +- [ecoinvent website](https://ecoinvent.org/) +- [ecoinvent version history](https://ecoinvent.org/the-ecoinvent-database/data-releases/) +- [brightway2-io documentation](https://docs.brightway.dev/projects/brightway2-io/) diff --git a/activity_browser/bwutils/io/README.md b/activity_browser/bwutils/io/README.md new file mode 100644 index 000000000..75814e1e0 --- /dev/null +++ b/activity_browser/bwutils/io/README.md @@ -0,0 +1,116 @@ +# io + +Import and export operations for LCA data interchange. + +## Overview + +This directory handles import and export operations for various LCA data formats, enabling data exchange between Activity Browser, Brightway2, and other LCA tools. + +## Purpose + +The io module provides: +- **Import** - Bring data from external sources into Brightway2 +- **Export** - Save Brightway2 data to various formats +- **Conversion** - Transform between different LCA data formats +- **Validation** - Check data integrity during import/export + +## Supported Formats + +### Import Formats +- **ecospold1/2** - Ecoinvent XML formats +- **SimaPro CSV** - SimaPro export format +- **Excel** - Custom Excel templates +- **JSON-LD** - Linked data format +- **ILCD** - International Reference Life Cycle Data System +- **Brightway2 packages** - BW2Package format + +### Export Formats +- **Excel** - Various Excel export templates +- **CSV** - Comma-separated values +- **Brightway2 packages** - For backup and sharing +- **SimaPro CSV** - For use in SimaPro + +## Architecture + +Import/export operations typically follow this pattern: + +1. **Selection** - User selects file(s) to import or export location +2. **Configuration** - Set options (database name, linking strategies, etc.) +3. **Processing** - Parse/transform data (often in a worker thread) +4. **Validation** - Check for errors or warnings +5. **Completion** - Write to database or save to file +6. **Feedback** - Report success, errors, or warnings to user + +## Threading + +Import/export operations use worker threads to avoid blocking the UI: + +```python +from activity_browser.ui.core.threading import ABThread + +worker = ABThread(import_function, args) +worker.finished.connect(on_complete) +worker.start() +``` + +## Error Handling + +Robust error handling is critical: +- Validate data before processing +- Provide clear error messages +- Allow partial success when possible +- Log errors for debugging +- Don't lose user data on failure + +## Usage Pattern + +```python +from activity_browser.bwutils.io import import_ecospold2 + +# Import with progress tracking +result = import_ecospold2( + filepath="data.ecospold", + database_name="my_database", + progress_callback=update_progress +) +``` + +## Integration with Actions + +Import/export is typically triggered via actions: + +```python +from activity_browser.app.actions.base import ABAction + +class ImportEcospold(ABAction): + @staticmethod + def run(): + # File selection dialog + filepath = get_file_path() + # Import in background thread + import_data(filepath) +``` + +## Development Guidelines + +When adding new import/export functionality: + +1. **Use worker threads** - Don't block the UI +2. **Provide progress updates** - Keep user informed +3. **Validate data** - Check before committing +4. **Handle errors gracefully** - Give helpful error messages +5. **Support cancellation** - Allow user to abort long operations +6. **Log operations** - Help with debugging +7. **Test with real data** - Use actual LCA databases for testing +8. **Document format specifics** - Note any format peculiarities or limitations + +## Strategies + +Import operations often use strategies to link exchanges: +- Match by name and location +- Match by code/UUID +- Match by CAS number +- Fuzzy matching +- Manual linking fallback + +See `bwutils/strategies.py` for strategy implementations. diff --git a/activity_browser/bwutils/metadata/README.md b/activity_browser/bwutils/metadata/README.md new file mode 100644 index 000000000..ea3d2e2b2 --- /dev/null +++ b/activity_browser/bwutils/metadata/README.md @@ -0,0 +1,141 @@ +# metadata + +Metadata management for activities, databases, and methods. + +## Overview + +This directory handles storage, retrieval, and management of metadata associated with LCA data in Activity Browser. Metadata provides additional context and information beyond what Brightway2 stores natively. + +## Purpose + +Metadata management provides: +- **Extended information** - Additional fields beyond Brightway2 schema +- **User annotations** - Comments, tags, custom fields +- **Workflow tracking** - Modification history, authorship +- **Search enhancement** - Additional searchable attributes +- **Classification** - Custom categorization schemes + +## Metadata Types + +### Activity Metadata +- Custom descriptions +- Data quality assessments +- Pedigree matrices +- User comments +- Modification timestamps +- Authorship information + +### Database Metadata +- Database descriptions +- Source information +- Version tracking +- Import history +- Licensing information + +### Method Metadata +- Method descriptions +- Methodological choices +- References and sources +- Uncertainty information + +## Storage + +Metadata is stored separately from Brightway2's native storage: +- JSON files in user data directory +- Keyed by activity/database/method identifiers +- Persisted across sessions +- Backed up with projects + +## MetaDataStore + +The `MetaDataStore` class (see `bwutils/metadata/`) provides centralized metadata access: + +```python +from activity_browser import app + +# Access metadata store +metadata = app.metadata + +# Get activity metadata +meta = metadata.get_activity_metadata(activity_key) + +# Update metadata +metadata.update_activity_metadata(activity_key, {"comment": "..."}) +``` + +## Usage Pattern + +### Reading Metadata +```python +meta = metadata.get_metadata(item_key) +comment = meta.get("comment", "") +``` + +### Writing Metadata +```python +metadata.update_metadata(item_key, { + "comment": "Updated description", + "modified": datetime.now().isoformat(), + "author": "user@example.com" +}) +``` + +### Searching Metadata +```python +results = metadata.search(query="renewable energy") +``` + +## Signal Integration + +Metadata changes emit signals: + +```python +from activity_browser import app + +app.signals.metadata_changed.emit(item_key) +``` + +Other components can listen and update their displays accordingly. + +## Development Guidelines + +When working with metadata: + +1. **Use the MetaDataStore** - Don't create separate storage +2. **Emit signals** - Notify when metadata changes +3. **Validate schemas** - Ensure metadata structure is consistent +4. **Handle missing data** - Provide sensible defaults +5. **Consider performance** - Cache frequently accessed metadata +6. **Backup regularly** - Metadata is user-created content +7. **Version metadata format** - Support migration if schema changes + +## Data Structure + +Typical metadata structure: + +```json +{ + "comment": "User-provided description", + "tags": ["renewable", "electricity"], + "data_quality": { + "reliability": 3, + "completeness": 4, + "temporal_correlation": 2 + }, + "modified": "2025-12-10T10:30:00", + "author": "user@example.com", + "custom_fields": { + "project_code": "ABC123" + } +} +``` + +## Integration with UI + +Metadata is displayed and edited through: +- Activity details page +- Database properties dialog +- Method information panel +- Custom metadata editor dialogs + +Users can add, edit, and delete metadata through these interfaces. diff --git a/activity_browser/bwutils/searchengine/README.md b/activity_browser/bwutils/searchengine/README.md new file mode 100644 index 000000000..b2c8accf5 --- /dev/null +++ b/activity_browser/bwutils/searchengine/README.md @@ -0,0 +1,179 @@ +# searchengine + +Search functionality for activities, exchanges, and other LCA data. + +## Overview + +This directory implements the search engine that enables users to quickly find activities, databases, methods, and other items across their LCA data. + +## Features + +### Full-Text Search +- Search across activity names +- Search in comments and descriptions +- Search in product/flow names +- Search in metadata fields + +### Filtered Search +- Filter by database +- Filter by location +- Filter by unit +- Filter by activity type + +### Advanced Search +- Boolean operators (AND, OR, NOT) +- Wildcard matching +- Regular expressions +- Field-specific queries + +### Fast Indexing +- Incremental index updates +- Background indexing +- Efficient data structures +- Cached results + +## Architecture + +The search engine consists of: + +1. **Indexer** - Builds searchable index from Brightway2 data +2. **Query Parser** - Parses user search queries +3. **Search Engine** - Performs actual search operations +4. **Result Ranker** - Orders results by relevance +5. **Cache** - Stores recent search results + +## Usage Pattern + +```python +from activity_browser.bwutils.searchengine import SearchEngine + +engine = SearchEngine() + +# Simple search +results = engine.search("electricity") + +# Filtered search +results = engine.search( + query="electricity", + database="ecoinvent", + location="CH" +) + +# Advanced search +results = engine.search("(wind OR solar) AND electricity") +``` + +## Index Management + +The search index is automatically maintained: +- Built on first use +- Updated when databases change +- Rebuilt when necessary +- Stored in user data directory + +### Triggering Updates +```python +from activity_browser import app + +# Index automatically updates on these signals: +app.signals.database_changed.emit() +app.signals.activity_modified.emit() +``` + +## Search Results + +Results include: +- Activity key (database, code) +- Activity name +- Product name +- Location +- Unit +- Relevance score +- Highlighted matches + +```python +for result in results: + print(f"{result['name']} ({result['location']})") + print(f"Score: {result['score']}") +``` + +## Performance Considerations + +### Optimization Strategies +- Index only relevant fields +- Use appropriate data structures (tries, inverted indexes) +- Cache frequent queries +- Limit result set size +- Lazy loading of full activity data + +### Threading +Search operations run in background threads: +```python +from activity_browser.ui.core.threading import ABThread + +worker = ABThread(engine.search, query) +worker.finished.connect(display_results) +worker.start() +``` + +## Search Syntax + +### Basic Search +``` +electricity +``` + +### Phrase Search +``` +"wind power" +``` + +### Boolean Operators +``` +wind AND electricity +solar OR wind +electricity NOT coal +``` + +### Field-Specific +``` +name:electricity location:CH unit:kWh +``` + +### Wildcards +``` +electr* # Prefix matching +*city # Suffix matching +el*city # Both +``` + +## Integration with UI + +Search is accessible via: +- Global search bar in toolbar +- Database browser filter +- Activity browser search +- Quick search dialogs +- Context menu search + +## Development Guidelines + +When working with search: + +1. **Index incrementally** - Update index, don't rebuild +2. **Run in background** - Don't block UI +3. **Limit results** - Provide pagination for large result sets +4. **Highlight matches** - Show why result matched +5. **Sort by relevance** - Put best matches first +6. **Support fuzzy matching** - Handle typos gracefully +7. **Cache wisely** - Balance memory vs. speed +8. **Profile performance** - Ensure searches complete quickly + +## Testing + +Test search with: +- Small and large databases +- Various query types +- Edge cases (special characters, unicode) +- Performance benchmarks +- Index rebuild scenarios diff --git a/activity_browser/bwutils/superstructure/README.md b/activity_browser/bwutils/superstructure/README.md new file mode 100644 index 000000000..efb2ddae2 --- /dev/null +++ b/activity_browser/bwutils/superstructure/README.md @@ -0,0 +1,179 @@ +# superstructure + +Superstructure scenario analysis tools for Activity Browser. + +## Overview + +This directory implements superstructure functionality, which enables scenario-based LCA analysis. Superstructures allow users to model multiple scenarios within a single database by using parameters to switch between alternative technologies, processes, or supply chains. + +## What is a Superstructure? + +A superstructure is an LCA model that contains multiple possible configurations: +- Alternative technologies (e.g., different energy sources) +- Multiple scenarios (e.g., current vs. future) +- Prospective databases (e.g., from [premise](https://premise.readthedocs.io/)) +- Switchable pathways (e.g., different material choices) + +Parameters control which alternatives are "active" in each scenario. + +## Key Concepts + +### Scenarios +Named configurations that define parameter values: +```python +scenarios = { + "baseline": {"electricity_grid_mix": 0.7, "renewable_share": 0.3}, + "high_renewable": {"electricity_grid_mix": 0.2, "renewable_share": 0.8} +} +``` + +### Parameters +Variables that control exchange amounts or activity selection: +- **Amount parameters** - Control exchange quantities +- **Switch parameters** - Enable/disable exchanges (0 or 1) +- **Share parameters** - Allocate between alternatives (sum to 1) + +### Alternative Processes +Multiple activities representing different technology choices: +- Linked via parameterized exchanges +- Only one "active" per scenario +- Controlled by parameter values + +## Features + +### Scenario Management +- Create, edit, delete scenarios +- Copy scenarios for variations +- Compare scenarios side-by-side +- Switch between scenarios + +### Parameter Configuration +- Define parameter ranges +- Set scenario-specific values +- Link parameters to exchanges +- Validate parameter consistency + +### Scenario Calculations +- Run LCA for multiple scenarios +- Compare results across scenarios +- Visualize scenario differences +- Export scenario results + +## Usage Pattern + +### Creating a Superstructure +```python +from activity_browser.bwutils.superstructure import Superstructure + +# Create superstructure +ss = Superstructure(name="Energy scenarios") + +# Add scenarios +ss.add_scenario("baseline", parameters={...}) +ss.add_scenario("high_renewable", parameters={...}) +``` + +### Running Scenario Analysis +```python +# Calculate all scenarios +results = ss.calculate_scenarios() + +# Compare results +comparison = ss.compare_scenarios(["baseline", "high_renewable"]) +``` + +## Integration with Parameters + +Superstructures leverage Activity Browser's parameter system: +- Project parameters define scenarios +- Database parameters set alternative values +- Activity parameters control exchanges +- Formulas link parameters together + +See `app/pages/parameters/` for parameter management UI. + +## Integration with Premise + +Activity Browser supports prospective databases from [premise](https://premise.readthedocs.io/): +- Import premise scenarios +- Map to Activity Browser scenarios +- Run temporal LCA analyses +- Visualize future pathways + +## Visualization + +Superstructure results can be visualized as: +- **Bar charts** - Compare impacts across scenarios +- **Radar charts** - Multi-dimensional scenario comparison +- **Heatmaps** - Parameter sensitivity across scenarios +- **Sankey diagrams** - Flow differences between scenarios + +## File Format + +Superstructures can be saved/loaded: +```json +{ + "name": "Energy scenarios", + "scenarios": { + "baseline": { + "parameters": {...}, + "description": "Current situation" + }, + "future": { + "parameters": {...}, + "description": "2050 scenario" + } + }, + "reference_flow": {...}, + "methods": [...] +} +``` + +## Development Guidelines + +When working with superstructures: + +1. **Validate parameters** - Ensure consistency across scenarios +2. **Check constraints** - Share parameters should sum to 1 +3. **Handle errors** - Gracefully handle missing or invalid parameters +4. **Use threading** - Scenario calculations can be slow +5. **Cache results** - Avoid recalculating unchanged scenarios +6. **Emit signals** - Notify when scenarios change +7. **Support undo** - Allow reverting parameter changes + +## Advanced Features + +### Sensitivity Analysis +Test parameter importance: +```python +sensitivity = ss.sensitivity_analysis( + parameter="renewable_share", + range=(0, 1), + steps=10 +) +``` + +### Optimization +Find best parameter values: +```python +optimal = ss.optimize( + objective="minimize_impact", + constraints={...} +) +``` + +### Monte Carlo with Scenarios +Combine uncertainty and scenarios: +```python +results = ss.monte_carlo( + scenario="future", + iterations=1000 +) +``` + +## Related Modules + +- `app/pages/parameters/` - Parameter management UI +- `bwutils/multilca.py` - Multi-functional LCA calculations +- `bwutils/sensitivity_analysis.py` - Sensitivity analysis tools +- `bwutils/montecarlo.py` - Monte Carlo simulation diff --git a/activity_browser/mod/README.md b/activity_browser/mod/README.md new file mode 100644 index 000000000..a1d206dc2 --- /dev/null +++ b/activity_browser/mod/README.md @@ -0,0 +1,58 @@ +# mod + +Monkey-patches and modifications to third-party libraries used by Activity Browser. + +## Overview + +This module contains patches and modifications to external libraries to fix bugs, add features, or adapt functionality for Activity Browser's specific needs. These modifications are applied at import time. + +## Directory Structure + +- **`bw2analyzer/`** - Patches for brightway2-analyzer +- **`bw2io/`** - Patches for brightway2-io +- **`ecoinvent_interface/`** - Patches for ecoinvent-interface +- **`peewee/`** - Patches for peewee ORM +- **`pyprind/`** - Patches for pyprind progress bars +- **`tqdm/`** - Patches for tqdm progress bars + +## Key Files + +- **`__init__.py`** - Imports all patched modules, replacing the original imports +- **`patching.py`** - Core patching utilities and helpers + +## How It Works + +When Activity Browser imports this module, it automatically imports the patched versions of external libraries. These patches are typically applied to: + +1. **Fix bugs** that haven't been addressed upstream +2. **Add Qt integration** for progress bars and UI elements +3. **Adapt functionality** to work better within a GUI context +4. **Add features** needed by Activity Browser but not available in the base libraries + +## Import Pattern + +The module is imported early in Activity Browser's initialization: + +```python +import activity_browser.mod.bw2analyzer as bw2analyzer +import activity_browser.mod.bw2io as bw2io +``` + +This ensures that the patched versions are used throughout the application. + +## Development Notes + +- Patches should be minimally invasive +- Document why each patch is needed +- Consider contributing fixes upstream when appropriate +- Test patches thoroughly as they modify external library behavior +- Keep patches up-to-date with upstream library versions + +## Warning + +Modifying third-party libraries can lead to maintenance challenges. Use this approach sparingly and only when: +- The issue can't be solved in Activity Browser code +- Upstream changes are not accepted or released +- The modification is essential for Activity Browser functionality + +Always prefer upstream contributions over local patches when possible. diff --git a/activity_browser/mod/bw2analyzer/README.md b/activity_browser/mod/bw2analyzer/README.md new file mode 100644 index 000000000..2c21210fb --- /dev/null +++ b/activity_browser/mod/bw2analyzer/README.md @@ -0,0 +1,185 @@ +# bw2analyzer + +Monkey-patches for brightway2-analyzer library. + +## Overview + +This directory contains modifications and patches to the brightway2-analyzer library. These patches fix bugs, add features, or adapt functionality specifically for Activity Browser's needs. + +## Key Files + +- **`contribution.py`** - Patches for contribution analysis functions +- **`__init__.py`** - Module initialization and patch application + +## Purpose + +Brightway2-analyzer provides LCA analysis tools including: +- Contribution analysis +- Graph traversal +- Tagged exchanges +- Monte Carlo analysis helpers + +Activity Browser patches this library to: +- Fix issues not yet addressed upstream +- Add GUI-specific functionality +- Improve performance for interactive use +- Handle edge cases + +## Contribution Analysis Patches + +The `contribution.py` file likely patches contribution analysis to: +- Handle large result sets more efficiently +- Provide progress callbacks for GUI +- Fix calculation edge cases +- Add sorting and filtering options +- Improve memory usage + +## Common Patches + +### Progress Callbacks +Add callbacks for long-running operations: +```python +def contribution_analysis(lca, progress_callback=None): + # Original function doesn't support callbacks + # Patch adds progress updates for GUI + for i, item in enumerate(items): + if progress_callback: + progress_callback(i / len(items) * 100) + # ... process item +``` + +### Error Handling +Improve error messages for GUI context: +```python +def patched_function(*args, **kwargs): + try: + return original_function(*args, **kwargs) + except Exception as e: + # Convert to user-friendly error + raise ABError(f"Analysis failed: {str(e)}") +``` + +### Performance Optimizations +Speed up operations for interactive use: +```python +def optimized_function(data): + # Add caching for repeated calls + cache_key = hash_data(data) + if cache_key in cache: + return cache[cache_key] + result = expensive_operation(data) + cache[cache_key] = result + return result +``` + +## Patch Application + +Patches are applied when the module is imported: + +```python +# In activity_browser/mod/__init__.py +import activity_browser.mod.bw2analyzer as bw2analyzer +``` + +This replaces the original bw2analyzer with the patched version. + +## Development Guidelines + +When adding patches: + +1. **Minimal changes** - Only patch what's necessary +2. **Document reasons** - Explain why each patch is needed +3. **Track upstream** - Monitor if fix is applied upstream +4. **Version awareness** - Handle different bw2analyzer versions +5. **Test thoroughly** - Ensure patches don't break existing functionality +6. **Consider alternatives** - Can it be done in AB code instead? + +## Contribution Analysis + +Typical contribution analysis patches might include: + +### Cutoff Support +```python +def contribution_analysis(lca, cutoff=0.01): + """Add cutoff parameter to limit results.""" + # Original doesn't support cutoff + # Patch filters results below threshold +``` + +### Sorting Options +```python +def contribution_analysis(lca, sort_by='amount'): + """Add sorting parameter.""" + # Original returns unsorted + # Patch adds sorting by amount, name, or impact +``` + +### Result Formatting +```python +def contribution_analysis(lca, format='dict'): + """Control output format.""" + # Original returns specific format + # Patch allows choosing format (dict, list, DataFrame) +``` + +## Testing Patches + +Test patches with: +- Unit tests for patched functions +- Integration tests with real LCA data +- Comparison with original behavior +- Edge cases and error conditions +- Performance benchmarks + +## Maintenance + +When updating Activity Browser: + +1. **Check brightway2-analyzer version** - New version may fix issues +2. **Review patches** - Are they still needed? +3. **Test compatibility** - Ensure patches work with new version +4. **Update if needed** - Adjust patches for API changes +5. **Contribute upstream** - Submit fixes to brightway2-analyzer + +## Alternative to Patching + +Instead of patching, consider: +- Wrapping functions in AB code +- Using composition instead of modification +- Contributing fixes directly to brightway2 +- Using configuration/options if available + +Patching should be last resort when: +- Upstream fix is not available +- Functionality is GUI-specific +- Performance optimization is needed +- Workaround is required + +## Risks of Patching + +Be aware that patches: +- May break with upstream updates +- Can cause confusion (behavior differs from docs) +- Require maintenance +- May conflict with other patches +- Complicate debugging + +## Documentation + +Always document: +- What is patched +- Why it's patched +- When it can be removed +- Any side effects +- Upstream issue tracking + +## Contributing Upstream + +When possible, contribute patches upstream: +1. Open issue on brightway2-analyzer +2. Propose fix or enhancement +3. Submit pull request +4. Maintain patch until merged +5. Remove patch once in released version + +This benefits the entire Brightway community and reduces AB maintenance burden. diff --git a/activity_browser/static/README.md b/activity_browser/static/README.md new file mode 100644 index 000000000..630b86ffd --- /dev/null +++ b/activity_browser/static/README.md @@ -0,0 +1,63 @@ +# static + +Static resources for the Activity Browser application. + +## Overview + +This directory contains all static assets used by Activity Browser including HTML templates, stylesheets, icons, fonts, JavaScript libraries, and other non-code resources. + +## Directory Structure + +- **`css/`** - Cascading Style Sheets for HTML views +- **`database_classifications/`** - Database classification mappings and schemas +- **`fonts/`** - Font files used in the application +- **`icons/`** - Application icons in various formats and sizes +- **`javascript/`** - JavaScript libraries and scripts for web views +- **`startscreen/`** - Start screen assets and templates + +## HTML Templates + +- **`activity_graph.html`** - Template for activity relationship graph visualization +- **`navigator.html`** - Base navigator template +- **`sankey_navigator.html`** - Sankey diagram visualization template +- **`spinner.html`** - Loading spinner template +- **`tree_navigator.html`** - Tree structure navigator template + +## Purpose + +These static resources support: + +1. **Visualization** - Interactive graphs, Sankey diagrams, and charts +2. **Branding** - Application icons and logo +3. **Styling** - Consistent look and feel across web views +4. **Classification** - Database and activity classification systems +5. **User Experience** - Welcome screens, loading indicators, navigation + +## Web Views + +Activity Browser embeds web views (Qt WebEngine) for rich interactive visualizations. These HTML templates use JavaScript libraries to render: + +- Force-directed graphs showing activity relationships +- Sankey diagrams for flow visualization +- Tree navigators for hierarchical data exploration +- Interactive charts and plots + +## Resource Loading + +Static resources are accessed via: + +```python +from pathlib import Path + +static_dir = Path(__file__).parent.resolve() / "static" +icon_path = static_dir / "icons" / "main_icon.png" +``` + +## Maintenance + +When adding new static resources: +- Place files in the appropriate subdirectory +- Ensure proper licensing for third-party assets +- Optimize file sizes (compress images, minify CSS/JS) +- Document dependencies and versions for JavaScript libraries +- Include resources in `MANIFEST.in` for packaging diff --git a/activity_browser/static/css/README.md b/activity_browser/static/css/README.md new file mode 100644 index 000000000..b44887c96 --- /dev/null +++ b/activity_browser/static/css/README.md @@ -0,0 +1,245 @@ +# css + +Cascading Style Sheets for Activity Browser's HTML views. + +## Overview + +This directory contains CSS files that style the HTML-based visualizations and web views in Activity Browser. These stylesheets control the appearance of graphs, Sankey diagrams, tree navigators, and other interactive visualizations. + +## Files + +- **`navigator.common.css`** - Common styles shared across navigators +- **`navigator.css`** - Base navigator styles +- **`activity_graph.css`** - Activity relationship graph styles +- **`sankey_navigator.css`** - Sankey diagram visualization styles +- **`tree_navigator.css`** - Tree structure navigator styles + +## Purpose + +These stylesheets provide: +- **Consistent appearance** - Unified look across all visualizations +- **Responsive design** - Adapt to different window sizes +- **Interactive styling** - Hover effects, selections, highlights +- **Theme support** - Match Activity Browser's overall design +- **Accessibility** - Readable colors, proper contrast + +## Common Patterns + +### Node Styling +```css +.node { + fill: #4a90e2; + stroke: #2c5aa0; + stroke-width: 2px; + cursor: pointer; +} + +.node:hover { + fill: #5da5ff; + stroke-width: 3px; +} + +.node.selected { + stroke: #ff6b6b; + stroke-width: 4px; +} +``` + +### Edge/Link Styling +```css +.link { + stroke: #999; + stroke-opacity: 0.6; + fill: none; +} + +.link:hover { + stroke-opacity: 1; + stroke-width: 3px; +} +``` + +### Text Styling +```css +.label { + font-family: Arial, sans-serif; + font-size: 12px; + fill: #333; + pointer-events: none; +} +``` + +## navigator.common.css + +Shared styles for all navigators: +- Layout and positioning +- Controls and buttons +- Tooltips +- Loading indicators +- Error messages + +## activity_graph.css + +Styles for activity relationship graphs: +- Node appearance (activities) +- Edge appearance (exchanges/relationships) +- Labels and annotations +- Graph controls (zoom, pan) +- Legend styling + +## sankey_navigator.css + +Styles for Sankey diagrams: +- Flow paths (width proportional to amount) +- Node boxes +- Flow colors (by category) +- Tooltips showing values +- Legend and scale + +## tree_navigator.css + +Styles for tree structures: +- Tree nodes (collapsible) +- Branches/connections +- Expand/collapse icons +- Indentation levels +- Selection highlighting + +## Color Schemes + +### Default Colors +- **Primary**: Blue (#4a90e2) +- **Secondary**: Green (#2ecc71) +- **Warning**: Orange (#f39c12) +- **Error**: Red (#e74c3c) +- **Neutral**: Gray (#95a5a6) + +### Category Colors +Different colors for flow types: +- **Technosphere**: Blue +- **Biosphere**: Green +- **Production**: Orange +- **Substitution**: Purple + +## Responsive Design + +Stylesheets adapt to window size: + +```css +@media (max-width: 768px) { + .node { + /* Smaller nodes on small screens */ + r: 4px; + } + + .label { + /* Smaller text on small screens */ + font-size: 10px; + } +} +``` + +## Interactive States + +### Hover States +Visual feedback when hovering: +```css +.interactive:hover { + opacity: 0.8; + cursor: pointer; +} +``` + +### Selection States +Highlight selected items: +```css +.selected { + stroke: #ff6b6b; + stroke-width: 3px; + filter: drop-shadow(0 0 5px rgba(255, 107, 107, 0.5)); +} +``` + +### Disabled States +Gray out disabled elements: +```css +.disabled { + opacity: 0.4; + cursor: not-allowed; +} +``` + +## Animations + +Smooth transitions: +```css +.node { + transition: all 0.3s ease; +} + +.link { + transition: stroke-width 0.2s ease, stroke-opacity 0.2s ease; +} +``` + +## Tooltips + +Styled tooltips for data display: +```css +.tooltip { + position: absolute; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + pointer-events: none; + z-index: 1000; +} +``` + +## Development Guidelines + +When modifying CSS: + +1. **Test in web view** - Not just browser (Qt WebEngine may differ) +2. **Use CSS variables** - For easy theme changes +3. **Mobile-first** - Design for smallest screens first +4. **Performance** - Avoid expensive effects on many elements +5. **Accessibility** - Maintain contrast ratios (WCAG AA) +6. **Cross-browser** - Test in different rendering engines +7. **Documentation** - Comment complex selectors +8. **Organization** - Group related styles +9. **Naming** - Use clear, descriptive class names +10. **Validation** - Run through CSS validator + +## CSS Variables + +Use CSS custom properties for theming: +```css +:root { + --primary-color: #4a90e2; + --text-color: #333; + --background-color: #fff; + --hover-opacity: 0.8; +} + +.node { + fill: var(--primary-color); +} +``` + +## Browser Compatibility + +Ensure compatibility with Qt WebEngine: +- Test rendering in actual application +- Check vendor prefixes +- Verify CSS feature support +- Test on all platforms (Windows, macOS, Linux) + +## Resources + +- [MDN CSS Reference](https://developer.mozilla.org/en-US/docs/Web/CSS) +- [CSS-Tricks](https://css-tricks.com/) +- [Can I Use](https://caniuse.com/) - Feature compatibility +- [D3.js Styling](https://d3js.org/) - SVG styling patterns diff --git a/activity_browser/static/icons/README.md b/activity_browser/static/icons/README.md new file mode 100644 index 000000000..ec6d698ec --- /dev/null +++ b/activity_browser/static/icons/README.md @@ -0,0 +1,214 @@ +# icons + +Application icons and graphical assets. + +## Overview + +This directory contains all icon files used throughout Activity Browser, including the application icon, toolbar icons, menu icons, and node type indicators. + +## Directory Structure + +- **`main/`** - Main application icon in various sizes and formats +- **`context/`** - Context menu icons +- **`nodes/`** - Node type icons (for graph visualizations) +- **`metaprocess/`** - Meta-process related icons + +## File Formats + +Icons are typically provided in multiple formats: +- **PNG** - Raster format with transparency (various sizes: 16x16, 24x24, 32x32, 48x48, 256x256) +- **SVG** - Vector format (scalable without quality loss) +- **ICO** - Windows icon format (contains multiple sizes) +- **ICNS** - macOS icon format + +## main/ + +Main application icon used for: +- Application window icon +- Taskbar/dock icon +- Desktop shortcut icon +- About dialog +- Installer icon + +Sizes provided: +- 16x16 - Taskbar, title bar +- 24x24 - Small toolbar buttons +- 32x32 - Medium toolbar buttons, list views +- 48x48 - Large icons +- 256x256 - High DPI displays, splash screen +- 512x512 - macOS Retina displays + +## context/ + +Icons for context menu actions: +- Copy +- Paste +- Delete +- Edit +- Open +- Save +- Export +- Import +- Search +- Refresh +- Settings + +## nodes/ + +Icons representing different node types in graphs: +- Activity nodes +- Product nodes +- Biosphere flow nodes +- Technosphere flow nodes +- Waste flow nodes +- Substitution nodes + +## metaprocess/ + +Icons for meta-process operations: +- Aggregation +- Disaggregation +- Grouping +- Filtering + +## Icon Loading + +Icons are loaded via `activity_browser/ui/icons.py`: + +```python +from activity_browser.ui.icons import get_icon + +# Load icon by name +icon = get_icon("save") + +# Use in action +action = QAction(get_icon("open"), "Open", parent) + +# Use in button +button = QPushButton(get_icon("delete"), "Delete") +``` + +## Icon Themes + +Activity Browser may support multiple icon themes: +- Light theme (dark icons on light background) +- Dark theme (light icons on dark background) +- High contrast theme (for accessibility) + +## Icon Design Guidelines + +When creating or modifying icons: + +### Size and Resolution +- Provide multiple sizes (16, 24, 32, 48, 256) +- Ensure clarity at smallest size (16x16) +- Use even dimensions for pixel-perfect rendering +- Support high DPI (2x, 3x scales) + +### Style Consistency +- Match existing icon style +- Use consistent line weights +- Maintain similar level of detail +- Use the same color palette + +### Visual Clarity +- Simple, recognizable shapes +- Clear at small sizes +- Sufficient contrast +- Not too much detail + +### Accessibility +- Work in light and dark themes +- Sufficient contrast ratios +- Distinct shapes (not just color differences) +- Test with colorblindness simulators + +### File Optimization +- Optimize PNG files (use tools like pngcrush) +- Clean up SVG files (remove unnecessary elements) +- Use transparency appropriately +- Keep file sizes small + +## Color Palette + +Standard colors used in Activity Browser icons: +- **Primary**: Blue (#4a90e2) +- **Success**: Green (#2ecc71) +- **Warning**: Orange (#f39c12) +- **Error**: Red (#e74c3c) +- **Info**: Cyan (#3498db) +- **Neutral**: Gray (#95a5a6) + +## Platform-Specific Icons + +### Windows +- Use ICO format for application icon +- Provide 16, 32, 48, 256 sizes in single ICO file +- Follow Windows icon guidelines + +### macOS +- Use ICNS format for application icon +- Provide 16, 32, 128, 256, 512, 1024 sizes +- Follow macOS icon guidelines +- Support Retina displays + +### Linux +- Use PNG for application icon +- Provide standard sizes: 16, 24, 32, 48, 64, 128, 256 +- Follow freedesktop.org icon naming spec +- Install to appropriate directories + +## Icon Attribution + +If using third-party icons: +- Check license compatibility (LGPL-compatible) +- Provide attribution if required +- Document source and license +- Consider creating custom icons instead + +## Tools for Icon Creation + +Recommended tools: +- **Inkscape** - Free vector graphics editor (SVG) +- **GIMP** - Free raster graphics editor (PNG) +- **ImageMagick** - Batch processing and conversion +- **icon-resizer** - Generate multiple sizes from SVG + +## Updating Icons + +When updating icons: +1. Edit source SVG file +2. Export to required PNG sizes +3. Optimize files +4. Generate platform-specific formats (ICO, ICNS) +5. Update icons in all directories +6. Test in application on all platforms +7. Verify high DPI rendering +8. Check light and dark themes + +## Icon Resources + +Free icon sources (check licenses): +- [Font Awesome](https://fontawesome.com/) +- [Material Icons](https://material.io/icons/) +- [Feather Icons](https://feathericons.com/) +- [Heroicons](https://heroicons.com/) + +## Testing Icons + +Test icons: +- At all sizes (16px to 256px) +- On different backgrounds +- With different themes +- On high DPI displays +- On all platforms +- In actual UI contexts + +## Maintenance + +Keep icons: +- Up-to-date with design trends +- Consistent with application style +- Optimized for performance +- Properly licensed +- Version controlled diff --git a/activity_browser/ui/README.md b/activity_browser/ui/README.md new file mode 100644 index 000000000..33ae27c71 --- /dev/null +++ b/activity_browser/ui/README.md @@ -0,0 +1,89 @@ +# ui + +Core UI components and widgets for the Activity Browser interface. + +## Overview + +This module contains reusable UI components, custom widgets, dialog windows, wizards, and web views that make up the Activity Browser user interface. These components are built using Qt (PySide6) via the qtpy compatibility layer. + +## Directory Structure + +- **`core/`** - Core UI classes including the application class, threading, and tree models +- **`delegates/`** - Qt item delegates for custom cell rendering in tables and trees +- **`dialogs/`** - Dialog windows for various user interactions +- **`web/`** - Web views for HTML-based visualizations +- **`widgets/`** - Reusable custom widget components +- **`wizards/`** - Multi-step wizard dialogs + +## Key Files + +- **`icons.py`** - Icon loading and management utilities + +## Core Components + +### `core/` +- **`application.py`** - `ABApplication` class extending QApplication with global shortcuts +- **`threading.py`** - Worker threads for background operations +- **`tree_model.py`** - Custom tree models for hierarchical data +- **`mimedata.py`** - Custom MIME data for drag-and-drop operations + +### `widgets/` +Custom reusable widgets: +- **`abstract_page.py`** - Base class for main content pages +- **`abstract_pane.py`** - Base class for dock panes +- **`buttons.py`** - Custom button widgets +- **`combobox.py`** - Enhanced combo box widgets +- **`tree_view.py`** - Custom tree view components +- **`table_view.py`** - Custom table view components +- **`plot.py`** - Plotting widgets +- And many more specialized widgets... + +## Design Patterns + +### Abstract Base Classes +Many widgets inherit from abstract base classes: +- `AbstractPage` - For main content area pages +- `AbstractPane` - For dock-able side panels + +### Custom Widgets +Widgets extend Qt base classes to add: +- Custom styling and appearance +- Application-specific behavior +- Signal/slot connections +- Validation and error handling + +### Delegates +Item delegates customize table/tree cell rendering and editing: +- Custom editors for specific data types +- Validation during inline editing +- Formatted display of values + +## Qt Integration + +All UI components use **qtpy** for Qt compatibility: + +```python +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt, Signal, Slot +``` + +This allows flexibility in Qt bindings (PySide6, PyQt6, etc.). + +## Usage Pattern + +Import widgets as needed: + +```python +from activity_browser.ui.widgets import AbstractPage +from activity_browser.ui.core.application import ABApplication +from activity_browser.ui.dialogs import MyDialog +``` + +## Development Guidelines + +- Inherit from abstract base classes when appropriate +- Use qtpy imports for Qt compatibility +- Connect to global signals for cross-component communication +- Keep widgets reusable and decoupled from application logic +- Follow Qt naming conventions (camelCase for methods) +- Emit signals for state changes rather than direct calls diff --git a/activity_browser/ui/core/README.md b/activity_browser/ui/core/README.md new file mode 100644 index 000000000..23cff1dca --- /dev/null +++ b/activity_browser/ui/core/README.md @@ -0,0 +1,178 @@ +# core + +Core UI classes and utilities for the Activity Browser interface. + +## Overview + +This directory contains fundamental UI classes that provide the foundation for Activity Browser's user interface, including the main application class, threading utilities, tree models, and MIME data handling. + +## Key Files + +### `application.py` +**ABApplication** - Main Qt application class + +Extends `QApplication` with Activity Browser-specific functionality: +- **Global shortcuts** - Register keyboard shortcuts across the application +- **Main window reference** - Centralized access to main window +- **Application lifecycle** - Startup, shutdown, event handling +- **Style management** - Application-wide styling and theming + +```python +from activity_browser.ui.core.application import ABApplication + +app = ABApplication() +app.main_window = main_window + +@app.global_shortcut("Ctrl+S") +def save_action(): + # Triggered by Ctrl+S anywhere in the app + pass +``` + +### `threading.py` +**ABThread** - Worker thread for background operations + +Provides threading utilities to keep the UI responsive: +- Run long operations in background +- Progress reporting +- Thread-safe signal emission +- Cancellation support + +```python +from activity_browser.ui.core.threading import ABThread + +def long_operation(progress_callback): + for i in range(100): + # Do work + progress_callback(i) + +worker = ABThread(long_operation) +worker.progress.connect(update_progress_bar) +worker.finished.connect(on_complete) +worker.start() +``` + +### `tree_model.py` +Custom tree models for hierarchical data display + +Implements Qt's model/view architecture for tree structures: +- **Efficient data handling** - Lazy loading of tree nodes +- **Custom data roles** - Additional data beyond display +- **Drag and drop** - Support for tree item manipulation +- **Filtering and sorting** - Built-in data organization + +```python +from activity_browser.ui.core.tree_model import TreeModel + +model = TreeModel(root_data) +tree_view.setModel(model) +``` + +### `mimedata.py` +Custom MIME data for drag-and-drop operations + +Defines MIME types for Activity Browser data: +- Activities +- Exchanges +- Databases +- Methods +- Parameters + +Enables drag-and-drop between different parts of the UI: + +```python +from activity_browser.ui.core.mimedata import ActivityMimeData + +# Create MIME data +mime = ActivityMimeData(activity_key) + +# Set on drag operation +drag = QDrag(widget) +drag.setMimeData(mime) +``` + +## Architecture Patterns + +### Application Singleton +`ABApplication` is a singleton accessed throughout the app: +```python +from activity_browser import app + +app.application # The ABApplication instance +``` + +### Threading Pattern +Long operations follow this pattern: +1. Create worker thread with target function +2. Connect signals (progress, finished, error) +3. Start thread (non-blocking) +4. Update UI via signals when complete + +### Model/View Pattern +Tree and table data uses Qt's model/view: +- **Model** - Data management and business logic +- **View** - Data display and user interaction +- **Delegate** - Custom cell rendering and editing + +## Global Shortcuts + +Register shortcuts using the decorator: +```python +@app.application.global_shortcut("Ctrl+F") +def find_action(): + # Search dialog or functionality + pass +``` + +Shortcuts are automatically attached when `app.main_window` is set. + +## Development Guidelines + +### Threading +- **Never block the main thread** - Use ABThread for slow operations +- **Update UI from main thread only** - Use signals to communicate back +- **Handle errors gracefully** - Catch exceptions in worker threads +- **Support cancellation** - Allow users to abort long operations + +### Models +- **Lazy loading** - Load data only when needed +- **Efficient updates** - Use beginInsertRows/endInsertRows properly +- **Custom roles** - Define additional data roles for internal use +- **Sort/filter proxies** - Use QSortFilterProxyModel for filtering + +### MIME Data +- **Use specific MIME types** - Define clear types for each data kind +- **Include sufficient data** - Store enough info for the drop target +- **Check compatibility** - Validate MIME data before accepting drops + +## Performance Considerations + +### Tree Models +- Implement lazy loading for large trees +- Cache frequently accessed data +- Use flat data structures when possible +- Batch updates with begin/end calls + +### Threading +- Pool threads for multiple small operations +- Cancel obsolete operations when new ones start +- Clean up thread resources properly +- Monitor thread count to avoid resource exhaustion + +## Signal/Slot Connections + +Core classes emit important signals: + +**ABThread**: +- `started` - Thread began execution +- `progress(int)` - Progress update (0-100) +- `finished` - Thread completed successfully +- `error(Exception)` - Thread encountered an error + +**TreeModel**: +- `dataChanged` - Model data was modified +- `rowsInserted` - New rows added +- `rowsRemoved` - Rows deleted +- `modelReset` - Model structure changed completely + +Connect to these signals to keep UI synchronized with data changes. diff --git a/activity_browser/ui/delegates/README.md b/activity_browser/ui/delegates/README.md new file mode 100644 index 000000000..e2307d0cc --- /dev/null +++ b/activity_browser/ui/delegates/README.md @@ -0,0 +1,222 @@ +# delegates + +Qt item delegates for custom cell rendering and editing in tables and trees. + +## Overview + +This directory contains custom Qt delegates that control how data is displayed and edited in table and tree views throughout Activity Browser. Delegates enable specialized rendering, validation, and editing behavior for different data types. + +## What are Delegates? + +In Qt's Model/View architecture, delegates handle: +- **Display** - How data appears in cells (colors, icons, formatting) +- **Editing** - What widget appears when user edits a cell +- **Validation** - Checking user input before accepting +- **Decoration** - Adding icons, colors, or other visual elements + +## Common Delegate Types + +### Numeric Delegates +- **Float delegate** - Editing decimal numbers with validation +- **Integer delegate** - Editing whole numbers with range limits +- **Percentage delegate** - Values with % formatting +- **Scientific notation delegate** - Large/small numbers + +### Text Delegates +- **String delegate** - Basic text with validation +- **Multiline delegate** - Text area for longer content +- **Formula delegate** - Parameter formula editing with syntax highlighting +- **Restricted text delegate** - Limited character sets + +### Selection Delegates +- **ComboBox delegate** - Drop-down selection from list +- **Checkbox delegate** - Boolean on/off values +- **Radio button delegate** - Mutually exclusive options +- **List delegate** - Multiple selections + +### Specialized Delegates +- **Unit delegate** - Unit selection with validation +- **Location delegate** - Geographic location picker +- **Database delegate** - Database selection +- **Activity delegate** - Activity selection with search + +## Usage Pattern + +Assign delegates to specific columns: + +```python +from activity_browser.ui.delegates import FloatDelegate + +table = QTableView() +table.setItemDelegateForColumn(2, FloatDelegate(parent=table)) +``` + +Or for all columns: + +```python +table.setItemDelegate(MyCustomDelegate(parent=table)) +``` + +## Creating Custom Delegates + +Inherit from `QStyledItemDelegate`: + +```python +from qtpy.QtWidgets import QStyledItemDelegate, QLineEdit + +class MyDelegate(QStyledItemDelegate): + def createEditor(self, parent, option, index): + """Create the editing widget.""" + editor = QLineEdit(parent) + editor.setValidator(...) # Add validation + return editor + + def setEditorData(self, editor, index): + """Load data into editor.""" + value = index.data(Qt.EditRole) + editor.setText(str(value)) + + def setModelData(self, editor, model, index): + """Save editor data back to model.""" + value = editor.text() + model.setData(index, value, Qt.EditRole) + + def displayText(self, value, locale): + """Format value for display.""" + return f"{value:.2f}" +``` + +## Key Methods + +### `createEditor(parent, option, index)` +Creates the widget used for editing: +- **parent** - Parent widget for the editor +- **option** - Style options for the item +- **index** - Model index being edited +- **Returns** - Editor widget (QLineEdit, QComboBox, etc.) + +### `setEditorData(editor, index)` +Populates the editor with current value: +- **editor** - The editor widget +- **index** - Model index with data + +### `setModelData(editor, model, index)` +Saves edited value back to model: +- **editor** - The editor widget +- **model** - The data model +- **index** - Model index to update + +### `displayText(value, locale)` +Formats value for display (optional): +- **value** - Raw data value +- **locale** - Locale for formatting +- **Returns** - Formatted string + +### `paint(painter, option, index)` +Custom rendering (advanced): +- **painter** - QPainter for drawing +- **option** - Style options +- **index** - Model index to render + +## Validation + +Add validators to editors: + +```python +def createEditor(self, parent, option, index): + editor = QLineEdit(parent) + validator = QDoubleValidator(0.0, 1000.0, 2, editor) + editor.setValidator(validator) + return editor +``` + +## Signal Handling + +Delegates can emit signals on edits: + +```python +from qtpy.QtCore import Signal + +class MyDelegate(QStyledItemDelegate): + editingFinished = Signal(QModelIndex, object) + + def setModelData(self, editor, model, index): + value = editor.text() + model.setData(index, value) + self.editingFinished.emit(index, value) +``` + +## Development Guidelines + +When creating delegates: + +1. **Inherit from QStyledItemDelegate** - Preferred over QItemDelegate +2. **Validate input** - Add QValidator to editors +3. **Handle edge cases** - Empty values, invalid data, cancellation +4. **Match data types** - Editor should match model data type +5. **Close editor properly** - Emit closeEditor signal when done +6. **Keep it simple** - Complex editing might need a dialog +7. **Test thoroughly** - Verify editing, validation, display +8. **Consider performance** - Efficient for many cells +9. **Support keyboard** - Tab, Enter, Escape navigation +10. **Provide feedback** - Visual cues for invalid input + +## Common Patterns + +### Combobox Delegate +```python +class ComboBoxDelegate(QStyledItemDelegate): + def __init__(self, items, parent=None): + super().__init__(parent) + self.items = items + + def createEditor(self, parent, option, index): + editor = QComboBox(parent) + editor.addItems(self.items) + return editor +``` + +### Checkbox Delegate +```python +class CheckBoxDelegate(QStyledItemDelegate): + def paint(self, painter, option, index): + # Draw checkbox centered in cell + checked = index.data(Qt.DisplayRole) + # Custom painting code... +``` + +### Formula Delegate with Validation +```python +class FormulaDelegate(QStyledItemDelegate): + def setModelData(self, editor, model, index): + formula = editor.text() + if self.validate_formula(formula): + model.setData(index, formula) + else: + # Show error, keep editor open + pass +``` + +## Integration with Views + +Tables and trees use delegates automatically: + +```python +# Create view and model +view = QTableView() +model = QStandardItemModel() +view.setModel(model) + +# Assign delegates +view.setItemDelegateForColumn(0, StringDelegate()) +view.setItemDelegateForColumn(1, FloatDelegate()) +view.setItemDelegateForColumn(2, ComboBoxDelegate(["A", "B", "C"])) +``` + +## Performance + +For large tables: +- Keep `paint()` methods efficient +- Cache formatted values when possible +- Avoid complex editors for many cells +- Consider virtual scrolling with delegates diff --git a/activity_browser/ui/dialogs/README.md b/activity_browser/ui/dialogs/README.md new file mode 100644 index 000000000..ae8cfbd87 --- /dev/null +++ b/activity_browser/ui/dialogs/README.md @@ -0,0 +1,269 @@ +# dialogs + +UI dialog windows for various user interactions. + +## Overview + +This directory contains dialog windows used throughout Activity Browser for user interactions such as configuration, data entry, item selection, and information display. + +## Dialog Categories + +### Input Dialogs +Collect information from users: +- Text input dialogs +- Numeric value entry +- Form-based data entry +- Multi-field configuration + +### Selection Dialogs +Allow users to choose items: +- Activity selection +- Database selection +- Method selection +- File/directory choosers +- List item selection + +### Configuration Dialogs +Manage settings and preferences: +- Application settings +- Project settings +- Database properties +- Import/export configuration +- Plugin configuration + +### Information Dialogs +Display information to users: +- About dialog +- Progress dialogs +- Status messages +- Error and warning dialogs +- Help and documentation + +### Confirmation Dialogs +Request user confirmation: +- Delete confirmations +- Overwrite warnings +- Action confirmations +- Discard changes prompts + +## Common Dialog Types + +### QDialog-based +Standard modal dialogs: +```python +from qtpy.QtWidgets import QDialog + +class MyDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def accept(self): + if self.validate(): + # Process and close + super().accept() +``` + +### QMessageBox-based +Simple message dialogs: +```python +from qtpy.QtWidgets import QMessageBox + +result = QMessageBox.question( + parent, + "Confirm Delete", + "Are you sure you want to delete this item?", + QMessageBox.Yes | QMessageBox.No +) +``` + +### QFileDialog-based +File and directory selection: +```python +from qtpy.QtWidgets import QFileDialog + +filepath = QFileDialog.getOpenFileName( + parent, + "Select File", + "", + "Excel files (*.xlsx)" +) +``` + +## Dialog Features + +### Modal vs. Modeless +- **Modal** - Blocks parent window until closed (most common) +- **Modeless** - Allows interaction with parent (for utilities) + +### Button Boxes +Standard button configurations: +```python +from qtpy.QtWidgets import QDialogButtonBox + +buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel +) +buttons.accepted.connect(self.accept) +buttons.rejected.connect(self.reject) +``` + +### Validation +Validate before accepting: +```python +def accept(self): + if not self.name_input.text(): + QMessageBox.warning(self, "Error", "Name is required") + return + super().accept() +``` + +### Progress Indication +Show progress for long operations: +```python +from qtpy.QtWidgets import QProgressDialog + +progress = QProgressDialog("Processing...", "Cancel", 0, 100, parent) +progress.setWindowModality(Qt.WindowModal) +progress.setValue(50) +``` + +## Usage Patterns + +### Simple Confirmation +```python +from qtpy.QtWidgets import QMessageBox + +reply = QMessageBox.question( + self, + "Confirm", + "Delete this database?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No # Default button +) + +if reply == QMessageBox.Yes: + # Perform deletion + pass +``` + +### Custom Dialog +```python +class MyDialog(QDialog): + def __init__(self, data, parent=None): + super().__init__(parent) + self.data = data + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + + # Add widgets + self.name_edit = QLineEdit() + layout.addWidget(QLabel("Name:")) + layout.addWidget(self.name_edit) + + # Add buttons + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def get_result(self): + """Return dialog result.""" + return self.name_edit.text() +``` + +### Using Custom Dialog +```python +dialog = MyDialog(data, parent=self) +if dialog.exec_() == QDialog.Accepted: + result = dialog.get_result() + # Use result +``` + +## Development Guidelines + +When creating dialogs: + +1. **Inherit from QDialog** - Use Qt's base dialog class +2. **Set parent** - Pass parent widget for proper hierarchy +3. **Provide clear title** - Set window title with setWindowTitle() +4. **Use button boxes** - Standard OK/Cancel buttons +5. **Validate input** - Check data in accept() method +6. **Return results** - Provide method to get dialog results +7. **Handle cancellation** - Clean up if user cancels +8. **Size appropriately** - Fit content, but not too large +9. **Be modal when needed** - Block parent for critical choices +10. **Show progress** - Use QProgressDialog for long operations + +## Threading in Dialogs + +Long operations should use worker threads: + +```python +from activity_browser.ui.core.threading import ABThread + +class MyDialog(QDialog): + def accept(self): + # Show progress + self.progress = QProgressDialog("Processing...", None, 0, 0, self) + self.progress.show() + + # Run in background + worker = ABThread(self.process_data) + worker.finished.connect(self.on_complete) + worker.start() + + def on_complete(self): + self.progress.close() + super().accept() +``` + +## Signal Integration + +Dialogs should emit signals for application updates: + +```python +from activity_browser import app + +class MyDialog(QDialog): + def accept(self): + # Save data + self.save_changes() + + # Notify application + app.signals.data_changed.emit() + + super().accept() +``` + +## Accessibility + +Make dialogs accessible: +- Clear focus order (tab navigation) +- Keyboard shortcuts for buttons +- Screen reader compatible labels +- Escape key to cancel +- Enter key to accept (when safe) + +## Testing + +Test dialogs thoroughly: +```python +def test_my_dialog(qtbot): + dialog = MyDialog() + qtbot.addWidget(dialog) + + # Test initial state + assert dialog.name_edit.text() == "" + + # Simulate user input + qtbot.keyClicks(dialog.name_edit, "Test Name") + + # Test validation + dialog.accept() + assert dialog.result() == QDialog.Accepted +``` diff --git a/activity_browser/ui/web/README.md b/activity_browser/ui/web/README.md new file mode 100644 index 000000000..d4c7ce935 --- /dev/null +++ b/activity_browser/ui/web/README.md @@ -0,0 +1,310 @@ +# web + +Web views for HTML-based visualizations and interactive content. + +## Overview + +This directory contains components for embedding web content within Activity Browser using Qt's WebEngine. These web views enable rich, interactive visualizations using HTML, CSS, and JavaScript. + +## Purpose + +Web views provide: +- **Interactive visualizations** - Sankey diagrams, force-directed graphs, trees +- **Rich content** - HTML-formatted reports and documentation +- **JavaScript libraries** - D3.js, Plotly, Cytoscape.js, etc. +- **External content** - Embedded web pages and resources + +## Qt WebEngine + +Activity Browser uses `QWebEngineView` (via qtpy): +- Chromium-based rendering engine +- Full HTML5, CSS3, JavaScript support +- Communication between Python and JavaScript +- Secure isolated context + +## Common Use Cases + +### Graph Visualizations +Force-directed graphs showing activity relationships: +- Node positioning algorithms +- Interactive exploration +- Zoom and pan +- Node/edge highlighting + +### Sankey Diagrams +Flow visualizations for LCA results: +- Material/energy flows +- Contribution analysis +- Interactive filtering +- Export to image + +### Tree Navigators +Hierarchical data exploration: +- Collapsible tree structures +- Search and filter +- Click to expand/collapse +- Path highlighting + +### Charts and Plots +Interactive data visualization: +- Line charts +- Bar charts +- Scatter plots +- Heatmaps +- Custom visualizations + +## Architecture + +### Python Side +```python +from qtpy.QtWebEngineWidgets import QWebEngineView +from qtpy.QtCore import QUrl + +class MyWebView(QWebEngineView): + def __init__(self, parent=None): + super().__init__(parent) + self.load_content() + + def load_content(self): + # Load from file + html_path = Path(__file__).parent / "template.html" + self.setUrl(QUrl.fromLocalFile(str(html_path))) + + def send_data_to_js(self, data): + # Execute JavaScript + js_code = f"updateData({json.dumps(data)});" + self.page().runJavaScript(js_code) +``` + +### JavaScript Side +```javascript +// In HTML template +function updateData(data) { + // Process data from Python + renderVisualization(data); +} + +// Send data to Python (via callback) +function notifyPython(message) { + // Setup bridge or use callback mechanism +} +``` + +## Python-JavaScript Communication + +### Python → JavaScript +Execute JavaScript from Python: +```python +self.page().runJavaScript("updateChart(data);") +``` + +With callback: +```python +def handle_result(result): + print(f"JavaScript returned: {result}") + +self.page().runJavaScript("getData();", handle_result) +``` + +### JavaScript → Python +Via `QWebChannel` (recommended): + +Python: +```python +from qtpy.QtWebChannel import QWebChannel +from qtpy.QtCore import QObject, pyqtSlot + +class Bridge(QObject): + @pyqtSlot(str) + def receive_message(self, message): + print(f"Received: {message}") + +channel = QWebChannel() +bridge = Bridge() +channel.registerObject("bridge", bridge) +self.page().setWebChannel(channel) +``` + +JavaScript: +```javascript +new QWebChannel(qt.webChannelTransport, function(channel) { + var bridge = channel.objects.bridge; + bridge.receive_message("Hello from JavaScript"); +}); +``` + +## HTML Templates + +Templates are stored in `activity_browser/static/`: +- `activity_graph.html` - Activity relationship graphs +- `sankey_navigator.html` - Sankey diagrams +- `tree_navigator.html` - Tree structures +- `navigator.html` - Base navigator template + +### Template Structure +```html + + + + + Visualization + + + + +
+ + + +``` + +## JavaScript Libraries + +Common libraries used: +- **D3.js** - Data-driven visualizations +- **Cytoscape.js** - Graph visualization and analysis +- **Plotly** - Interactive charts +- **vis.js** - Network and timeline visualizations +- **Sigma.js** - Graph rendering + +## Loading Content + +### From File +```python +from pathlib import Path +from qtpy.QtCore import QUrl + +html_file = Path(__file__).parent / "static" / "graph.html" +self.setUrl(QUrl.fromLocalFile(str(html_file))) +``` + +### From String +```python +html_content = """ + + + +

Hello World

+ + +""" +self.setHtml(html_content) +``` + +### From URL +```python +self.setUrl(QUrl("https://example.com")) +``` + +## Development Guidelines + +When creating web views: + +1. **Use templates** - Store HTML in static/ directory +2. **Isolate code** - Separate HTML, CSS, JavaScript files +3. **Handle loading** - Show spinner while content loads +4. **Error handling** - Handle JavaScript errors gracefully +5. **Responsive design** - Handle window resizing +6. **Secure content** - Validate external resources +7. **Performance** - Optimize for large datasets +8. **Testing** - Test in actual web view (not just browser) +9. **Communication** - Use QWebChannel for Python↔JS +10. **Documentation** - Document expected data format + +## Data Transfer + +### Sending Large Data +For large datasets, consider: +- JSON serialization +- Chunked transfer +- Data compression +- Lazy loading + +```python +import json + +def send_data(self, data): + json_data = json.dumps(data) + js_code = f"loadData({json_data});" + self.page().runJavaScript(js_code) +``` + +### Receiving Data +```python +def request_data(self): + def callback(result): + data = json.loads(result) + self.process_data(data) + + self.page().runJavaScript("getData();", callback) +``` + +## Performance Optimization + +### Large Datasets +- Render subsets (pagination, windowing) +- Use canvas instead of SVG for many elements +- Implement level-of-detail +- Cache rendered content + +### Interactions +- Debounce frequent events +- Throttle animations +- Use requestAnimationFrame +- Optimize DOM manipulation + +## Debugging + +### JavaScript Console +Access console from Python: +```python +def on_console_message(self, level, message, line, source): + print(f"JS: {message} (line {line})") + +self.page().javaScriptConsoleMessage = on_console_message +``` + +### Developer Tools +Enable debugging (development only): +```python +from qtpy.QtWebEngineWidgets import QWebEngineSettings + +settings = self.page().settings() +settings.setAttribute( + QWebEngineSettings.DeveloperExtrasEnabled, + True +) +``` + +## Example: Simple Graph View + +```python +from qtpy.QtWebEngineWidgets import QWebEngineView +from pathlib import Path +import json + +class GraphView(QWebEngineView): + def __init__(self, parent=None): + super().__init__(parent) + + # Load template + template = Path(__file__).parent / "graph.html" + self.setUrl(QUrl.fromLocalFile(str(template))) + + def display_graph(self, nodes, edges): + """Send graph data to JavaScript.""" + data = { + "nodes": nodes, + "edges": edges + } + js = f"renderGraph({json.dumps(data)});" + self.page().runJavaScript(js) +``` + +## Security Considerations + +- **Validate external content** - Don't load untrusted URLs +- **Sanitize data** - Escape user input before sending to JS +- **Content Security Policy** - Restrict resource loading +- **HTTPS for external** - Use secure connections +- **Isolate sensitive data** - Don't expose secrets to JS diff --git a/activity_browser/ui/widgets/README.md b/activity_browser/ui/widgets/README.md new file mode 100644 index 000000000..cb3bc002e --- /dev/null +++ b/activity_browser/ui/widgets/README.md @@ -0,0 +1,202 @@ +# widgets + +Reusable custom widget components for the Activity Browser interface. + +## Overview + +This directory contains a collection of custom Qt widgets used throughout Activity Browser. These widgets extend Qt's base widgets with application-specific functionality, styling, and behavior. + +## Key Files + +### Abstract Base Classes +- **`abstract_page.py`** - Base class for main content area pages +- **`abstract_pane.py`** - Base class for dock-able side panels + +### Layout and Container Widgets +- **`central.py`** - Central widget that holds the main content area +- **`dock_widget.py`** - Custom dock widget with additional features +- **`tab_widget.py`** - Enhanced tab widget with custom styling + +### Input Widgets +- **`line_edit.py`** - Enhanced single-line text input +- **`text_edit.py`** - Multi-line text editor with additional features +- **`combobox.py`** - Drop-down selection with search and filtering +- **`formula_edit.py`** - Specialized editor for parameter formulas +- **`database_name_edit.py`** - Input widget for database names with validation + +### Display Widgets +- **`label.py`** - Custom labels with additional styling options +- **`tree_view.py`** - Enhanced tree view for hierarchical data +- **`plot.py`** - Plotting widgets for charts and graphs + +### Interactive Widgets +- **`buttons.py`** - Custom button variations (icon buttons, toggle buttons) +- **`button_collapser.py`** - Collapsible sections with expand/collapse buttons +- **`comparison_switch.py`** - Switch between different comparison views +- **`cutoff_menu.py`** - Menu for selecting cutoff thresholds +- **`menu.py`** - Enhanced context and popup menus + +### Utility Widgets +- **`file_selector.py`** - File/directory selection with browse button +- **`drop_overlay.py`** - Visual overlay for drag-and-drop operations +- **`line.py`** - Visual separator lines + +### Wizards +- **`wizard.py`** - Base wizard dialog for multi-step workflows +- **`wizard_page.py`** - Individual pages within wizards + +## Widget Categories + +### Page Widgets (AbstractPage) +Main content pages inherit from `AbstractPage`: +- Consistent toolbar integration +- Signal connection handling +- State management +- Layout conventions + +```python +from activity_browser.ui.widgets import AbstractPage + +class MyPage(AbstractPage): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() +``` + +### Pane Widgets (AbstractPane) +Dock-able panes inherit from `AbstractPane`: +- Dock widget functionality +- Visibility persistence +- Resize handling +- Title bar customization + +```python +from activity_browser.ui.widgets import AbstractPane + +class MyPane(AbstractPane): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_content() +``` + +### Input Widgets +Enhanced input widgets with: +- Validation +- Placeholder text +- Clear buttons +- Auto-completion +- Format enforcement + +### Display Widgets +Specialized display widgets: +- Custom rendering +- Context menus +- Copy/export functionality +- Sorting and filtering + +## Common Patterns + +### Signal Connections +Widgets connect to global signals: +```python +from activity_browser import app + +app.signals.data_changed.connect(self.refresh) +``` + +### Validation +Input widgets validate data: +```python +class MyLineEdit(QLineEdit): + def validate_input(self): + if not self.text().strip(): + self.setStyleSheet("border: 1px solid red") + return False + return True +``` + +### Context Menus +Many widgets provide context menus: +```python +def contextMenuEvent(self, event): + menu = QMenu(self) + menu.addAction("Copy", self.copy_selection) + menu.addAction("Export", self.export_data) + menu.exec_(event.globalPos()) +``` + +## Styling + +Widgets use Qt stylesheets for consistent appearance: + +```python +self.setStyleSheet(""" + QWidget { + background-color: #ffffff; + color: #000000; + } + QPushButton { + border: 1px solid #cccccc; + border-radius: 3px; + padding: 5px; + } +""") +``` + +## Development Guidelines + +When creating custom widgets: + +1. **Inherit from appropriate base class** - Use AbstractPage/AbstractPane when applicable +2. **Emit signals for state changes** - Enable other components to react +3. **Support keyboard navigation** - Implement tab order and shortcuts +4. **Provide context menus** - Right-click actions for common operations +5. **Validate input** - Check data before accepting +6. **Handle errors gracefully** - Show user-friendly error messages +7. **Use consistent styling** - Follow application design patterns +8. **Document public API** - Docstrings for public methods and signals +9. **Make widgets reusable** - Avoid hard-coding application logic +10. **Test widgets independently** - Unit tests for widget behavior + +## Reusability + +Widgets should be: +- **Self-contained** - Minimal external dependencies +- **Configurable** - Properties for customization +- **Composable** - Can be combined into complex UIs +- **Generic** - Not tied to specific data models + +## Accessibility + +Consider accessibility: +- Keyboard navigation +- Screen reader compatibility +- High contrast support +- Focus indicators +- Logical tab order + +## Performance + +Optimize widget performance: +- Lazy loading of data +- Virtual scrolling for large lists +- Efficient repainting +- Debounced event handlers +- Cache computed values + +## Testing + +Widget tests should verify: +- Initial state and defaults +- User interactions (clicks, text entry) +- Signal emission +- Validation logic +- Edge cases and error handling + +Use pytest-qt for testing: +```python +def test_my_widget(qtbot): + widget = MyWidget() + qtbot.addWidget(widget) + # Test widget behavior +``` diff --git a/activity_browser/ui/wizards/README.md b/activity_browser/ui/wizards/README.md new file mode 100644 index 000000000..dff2884c7 --- /dev/null +++ b/activity_browser/ui/wizards/README.md @@ -0,0 +1,295 @@ +# wizards + +Multi-step wizard dialogs for complex workflows. + +## Overview + +This directory contains wizard dialogs that guide users through multi-step processes such as database import, project setup, and configuration tasks. Wizards provide a structured approach to complex operations. + +## What is a Wizard? + +A wizard is a multi-page dialog that: +- Guides users step-by-step through a process +- Validates input at each step +- Allows forward/backward navigation +- Shows progress through the workflow +- Collects all necessary information before completion + +## When to Use Wizards + +Use wizards for: +- **Complex setup** - Initial configuration with many options +- **Multi-step workflows** - Processes requiring sequential steps +- **Data collection** - Gathering information in logical groups +- **Import/export** - File selection, options, mapping, preview +- **Guided operations** - Help users through unfamiliar tasks + +Don't use wizards for: +- Simple forms (use a regular dialog) +- Single-step operations +- Expert users who know what they want +- When flexibility in order is needed + +## Wizard Components + +### Wizard Dialog +The main container (inherits from `QWizard`): +- Manages pages +- Handles navigation +- Provides standard buttons (Next, Back, Finish, Cancel) +- Tracks completion state + +### Wizard Pages +Individual steps (inherit from `QWizardPage`): +- Collect specific information +- Validate input +- Determine if page is complete +- Navigate to next page + +## Usage Pattern + +### Creating a Wizard + +```python +from qtpy.QtWidgets import QWizard + +class MyWizard(QWizard): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("My Wizard") + + # Add pages + self.addPage(IntroPage()) + self.addPage(ConfigPage()) + self.addPage(ConfirmPage()) + + def accept(self): + # Process collected data + self.process_results() + super().accept() +``` + +### Creating Wizard Pages + +```python +from qtpy.QtWidgets import QWizardPage, QVBoxLayout, QLineEdit + +class ConfigPage(QWizardPage): + def __init__(self, parent=None): + super().__init__(parent) + self.setTitle("Configuration") + self.setSubTitle("Enter configuration details") + + layout = QVBoxLayout(self) + self.name_edit = QLineEdit() + self.registerField("name*", self.name_edit) # * = required + layout.addWidget(self.name_edit) + + def validatePage(self): + """Called when Next is clicked.""" + if not self.name_edit.text(): + return False + return True +``` + +## Wizard Features + +### Field Registration +Share data between pages: +```python +# In page 1 +self.registerField("database_name*", self.name_edit) + +# In page 2 +db_name = self.field("database_name") +``` + +### Required Fields +Mark fields as required (asterisk): +```python +self.registerField("email*", self.email_edit) +``` + +Next button is disabled until all required fields are filled. + +### Page Validation +Control when users can proceed: +```python +def validatePage(self): + """Return False to prevent proceeding.""" + if not self.validate_input(): + QMessageBox.warning(self, "Error", "Invalid input") + return False + return True +``` + +### Conditional Navigation +Skip pages based on choices: +```python +def nextId(self): + """Return ID of next page.""" + if self.skip_option.isChecked(): + return self.SummaryPage # Skip intermediate pages + return super().nextId() +``` + +### Dynamic Content +Update pages based on previous choices: +```python +def initializePage(self): + """Called when page is shown.""" + selection = self.field("user_selection") + self.update_options(selection) +``` + +## Wizard Buttons + +### Standard Buttons +- **Next** - Proceed to next page (calls `validatePage()`) +- **Back** - Return to previous page +- **Finish** - Complete wizard (calls `accept()`) +- **Cancel** - Abort wizard (calls `reject()`) +- **Help** - Show help (optional, connect to help system) + +### Customizing Buttons +```python +self.setButtonText(QWizard.NextButton, "Continue") +self.setButtonText(QWizard.FinishButton, "Import") +``` + +### Button Visibility +```python +self.button(QWizard.BackButton).setVisible(False) +``` + +## Wizard Styles + +Choose wizard style: +```python +# Modern style (default) +wizard.setWizardStyle(QWizard.ModernStyle) + +# Classic style with sidebar +wizard.setWizardStyle(QWizard.ClassicStyle) + +# Mac style +wizard.setWizardStyle(QWizard.MacStyle) +``` + +## Page Types + +### Intro Page +Welcome and overview: +```python +class IntroPage(QWizardPage): + def __init__(self): + super().__init__() + self.setTitle("Welcome") + layout = QVBoxLayout(self) + layout.addWidget(QLabel("This wizard will guide you...")) +``` + +### Input Page +Collect user input: +```python +class InputPage(QWizardPage): + def __init__(self): + super().__init__() + self.setTitle("Enter Information") + # Add input fields +``` + +### Selection Page +Choose options: +```python +class SelectionPage(QWizardPage): + def __init__(self): + super().__init__() + self.setTitle("Select Options") + # Add radio buttons or checkboxes +``` + +### Preview Page +Review before finishing: +```python +class PreviewPage(QWizardPage): + def initializePage(self): + # Show summary of all choices + summary = self.generate_summary() + self.label.setText(summary) +``` + +## Threading in Wizards + +Long operations in `accept()`: +```python +def accept(self): + # Show progress + self.progress = QProgressDialog("Importing...", None, 0, 0, self) + self.progress.show() + + # Run in background + worker = ABThread(self.import_data) + worker.finished.connect(self.on_complete) + worker.start() + +def on_complete(self): + self.progress.close() + super().accept() +``` + +## Development Guidelines + +When creating wizards: + +1. **Plan page flow** - Map out all steps before coding +2. **Keep pages focused** - One task per page +3. **Validate early** - Check input on each page +4. **Provide context** - Clear titles and subtitles +5. **Use fields wisely** - Register fields to share data +6. **Enable navigation** - Implement validatePage() properly +7. **Show progress** - Use page numbers or progress indicator +8. **Provide help** - Add help button with useful information +9. **Test all paths** - Verify all navigation possibilities +10. **Handle cancellation** - Clean up partial work + +## Example: Import Wizard + +```python +class ImportWizard(QWizard): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Import Database") + + self.file_page = FileSelectionPage() + self.options_page = ImportOptionsPage() + self.preview_page = PreviewPage() + + self.addPage(self.file_page) + self.addPage(self.options_page) + self.addPage(self.preview_page) + + def accept(self): + filepath = self.field("filepath") + options = self.get_options() + self.perform_import(filepath, options) + super().accept() +``` + +## Integration with Actions + +Wizards are typically opened via actions: + +```python +from activity_browser.app.actions.base import ABAction + +class OpenImportWizard(ABAction): + text = "Import Database..." + + @staticmethod + def run(): + wizard = ImportWizard() + if wizard.exec_() == QWizard.Accepted: + # Import completed successfully + pass +``` diff --git a/activity_browser_beta/README.md b/activity_browser_beta/README.md new file mode 100644 index 000000000..dae0996ee --- /dev/null +++ b/activity_browser_beta/README.md @@ -0,0 +1,154 @@ +# activity_browser_beta + +Beta version package for Activity Browser. + +## Overview + +This is a separate package for the Activity Browser beta version (Version 3.0). It allows users to install and test beta features alongside the stable version without conflicts. + +## Purpose + +The beta package: +- **Parallel Installation** - Can coexist with stable version +- **Early Access** - Test new features before general release +- **Feedback** - Help improve Activity Browser through testing +- **Brightway 2.5** - Uses the latest Brightway version + +## Key Features in Beta + +- **Brightway 2.5 support** - Uses the latest Brightway framework +- **Multi-functionality** - Enhanced support for multi-functional processes +- **Improved performance** - Optimizations for large databases +- **New UI elements** - Updated interface components +- **Enhanced calculations** - Better calculation setup and management + +## Installation + +### From conda-forge +```bash +conda install -c conda-forge activity-browser-beta +``` + +### Alongside Stable Version +Both versions can be installed in different environments: + +```bash +# Stable version in one environment +conda create -n ab-stable -c conda-forge activity-browser + +# Beta version in another environment +conda create -n ab-beta -c conda-forge activity-browser-beta +``` + +## Running Beta + +Launch the beta version: +```bash +activity-browser-beta +``` + +Or from Python: +```python +from activity_browser_beta import run_activity_browser +run_activity_browser() +``` + +## Package Structure + +The `__init__.py` file likely re-exports or wraps the main activity_browser package with beta-specific configurations or modifications. + +## Data Compatibility + +### Projects +Beta may create or modify projects in ways incompatible with stable: +- **Separate projects** - Use different project names for beta testing +- **Backup first** - Always backup projects before testing beta +- **One-way migration** - Some changes may not be reversible + +### Databases +- Beta may support features not available in stable +- Database format changes may prevent opening in stable version +- Export/import may be needed to move data between versions + +## Reporting Issues + +Report beta issues on GitHub: +- Label issues with "beta" tag +- Specify you're using the beta version +- Include version number from Help → About +- Describe expected vs actual behavior +- Provide steps to reproduce + +## Transitioning to Stable + +When beta becomes stable: +1. Beta features are merged into main release +2. activity-browser-beta package is deprecated +3. Users migrate to standard activity-browser package +4. Projects created in beta work in new stable version + +## Development + +The beta package is typically: +- Built from a beta branch in the repository +- Tagged with beta version numbers (e.g., 3.0.0b1) +- Distributed via conda-forge beta channel +- Updated more frequently than stable + +## Feedback + +Help improve Activity Browser by: +- Testing new features +- Reporting bugs +- Suggesting improvements +- Comparing with stable version +- Documenting workflows + +Submit feedback: +- GitHub Issues: https://github.com/LCA-ActivityBrowser/activity-browser/issues +- Discussions: https://github.com/LCA-ActivityBrowser/activity-browser/discussions +- Email maintainers + +## Documentation + +Beta documentation is available at: +https://lca-activitybrowser.github.io/activity-browser/beta.html + +## Caution + +Beta software: +- **May have bugs** - Expect issues +- **May change** - Features may be modified or removed +- **May be unstable** - Crashes possible +- **Not for production** - Don't use for critical work +- **Data loss risk** - Always backup your data + +## Best Practices + +When testing beta: +1. **Create test projects** - Don't use real project data +2. **Backup everything** - Projects, databases, custom data +3. **Document issues** - Take notes on problems +4. **Compare with stable** - Verify behavior differences +5. **Separate environments** - Use dedicated conda environment +6. **Stay updated** - Check for beta updates regularly +7. **Read release notes** - Understand what changed +8. **Provide feedback** - Share your experience + +## Version Numbering + +Beta versions use pre-release identifiers: +- `3.0.0b1` - First beta +- `3.0.0b2` - Second beta +- `3.0.0rc1` - Release candidate +- `3.0.0` - Stable release + +## Support + +Beta support: +- Community support via GitHub Discussions +- Issue tracker for bug reports +- Limited email support +- Self-service documentation + +Remember: Beta software is experimental. Use at your own risk and always maintain backups of important data. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..30a08a194 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,205 @@ +# docs + +Documentation for Activity Browser. + +## Overview + +This directory contains the source files for Activity Browser's documentation website, which is built using Jekyll and hosted on GitHub Pages. + +## Structure + +- **Jekyll Site Configuration** + - `_config.yml` - Jekyll site configuration + - `Gemfile` - Ruby gem dependencies + - `404.html` - Custom 404 error page + - `index.md` - Documentation homepage + +- **`_includes/`** - Reusable HTML/Liquid templates + - `nav_footer_custom.html` - Custom navigation footer + - `search_placeholder_custom.html` - Custom search placeholder + +- **`_sass/`** - SASS/CSS stylesheets + - `custom/` - Custom styling overrides + +- **`getting-started/`** - Getting started guides + - `installation.md` - Installation instructions + - `project-setup.md` - Setting up your first project + - `creating-databases.md` - Creating and managing databases + - `building-models.md` - Building LCA models + - `lca-calculations.md` - Running LCA calculations + - `index.md` - Getting started overview + +- **`user-interface/`** - UI documentation + - `pages/` - Documentation for each page + - `index.md` - UI overview + +- **`advanced-topics/`** - Advanced features + - `project-structure.md` - Understanding project structure + - `scenario-calculations.md` - Scenario analysis + - `brightway-legacy.md` - Working with Brightway legacy versions + - `multifunctional-databases/` - Multi-functionality documentation + - `index.md` - Advanced topics overview + +- **`assets/`** - Images, screenshots, and other assets + +- **`beta.md`** - Beta version information + +## Building Documentation + +### Prerequisites +- Ruby (for Jekyll) +- Bundler gem + +### Local Development + +1. Install dependencies: + ```bash + cd docs + bundle install + ``` + +2. Serve locally: + ```bash + bundle exec jekyll serve + ``` + +3. View at: `http://localhost:4000` + +### Live Documentation + +The documentation is automatically built and deployed to GitHub Pages when changes are pushed to the repository. + +URL: [https://lca-activitybrowser.github.io/activity-browser/](https://lca-activitybrowser.github.io/activity-browser/) + +## Writing Documentation + +### Markdown Files + +Documentation is written in Markdown with Jekyll front matter: + +```markdown +--- +layout: default +title: Page Title +nav_order: 1 +--- + +# Page Title + +Content goes here... +``` + +### Front Matter Options + +- **`layout`** - Page layout template (usually `default`) +- **`title`** - Page title +- **`nav_order`** - Navigation menu order +- **`parent`** - Parent page for nested navigation +- **`has_children`** - Whether page has child pages +- **`permalink`** - Custom URL path + +### Linking Pages + +Use relative links: +```markdown +See [Installation Guide]({% link getting-started/installation.md %}) +``` + +### Including Images + +Place images in `assets/` and reference: +```markdown +![Screenshot](../assets/screenshot.png) +``` + +### Code Blocks + +Use fenced code blocks with language: +```markdown +```python +import bw2data as bd +bd.projects.set_current("my_project") +``` +``` + +## Documentation Structure + +### Getting Started +Target audience: New users +- Installation +- First project +- Basic concepts +- First calculation + +### User Interface +Target audience: All users +- Navigation +- Pages and panes +- Common tasks +- Keyboard shortcuts + +### Advanced Topics +Target audience: Power users +- Scenarios and parameters +- Uncertainty analysis +- Sensitivity analysis +- Multi-functionality +- Integration with Brightway + +## Style Guide + +### Writing Style +- **Clear and concise** - Simple language +- **Task-oriented** - Focus on what users want to do +- **Step-by-step** - Break down complex tasks +- **Visual aids** - Screenshots and diagrams +- **Examples** - Show real examples + +### Formatting +- **Headings** - Use proper hierarchy (H1, H2, H3) +- **Lists** - For steps or multiple items +- **Bold** - For UI elements and important terms +- **Code** - For code, commands, and file paths +- **Notes/Tips** - Use blockquotes for callouts + +### Screenshots +- Use actual application screenshots +- Highlight relevant areas +- Keep up-to-date with current UI +- Crop to show only relevant content +- Use consistent window size + +## Maintenance + +### Keeping Current +- Update screenshots when UI changes +- Verify instructions after code changes +- Add documentation for new features +- Mark deprecated features +- Update version numbers + +### Review Process +- Test instructions on fresh install +- Check all links work +- Verify code examples +- Review for clarity +- Check mobile responsiveness + +## Contributing + +To contribute to documentation: + +1. Fork the repository +2. Create a branch for your changes +3. Edit/add Markdown files in `docs/` +4. Test locally with Jekyll +5. Submit a pull request + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for more details. + +## Resources + +- [Jekyll Documentation](https://jekyllrb.com/docs/) +- [Just the Docs Theme](https://just-the-docs.github.io/just-the-docs/) +- [Markdown Guide](https://www.markdownguide.org/) +- [GitHub Pages](https://pages.github.com/) diff --git a/recipe/README.md b/recipe/README.md new file mode 100644 index 000000000..3a2bb6547 --- /dev/null +++ b/recipe/README.md @@ -0,0 +1,217 @@ +# recipe + +Conda build recipe for Activity Browser. + +## Overview + +This directory contains the conda-build recipe for packaging and distributing Activity Browser via conda-forge. The recipe defines how to build the conda package from source. + +## Key File + +- **`meta.yaml`** - Conda package metadata and build instructions + +## meta.yaml Structure + +The `meta.yaml` file contains several sections: + +### Package Section +Defines package name and version: +```yaml +package: + name: activity-browser + version: {{ VERSION }} +``` + +### Source Section +Specifies where to get the source code: +```yaml +source: + path: .. # Local path for development + # Or from GitHub release: + # url: https://github.com/LCA-ActivityBrowser/activity-browser/archive/{{ version }}.tar.gz +``` + +### Build Section +Build configuration: +```yaml +build: + number: 0 + noarch: python # Pure Python package + entry_points: + - activity-browser = activity_browser:run_activity_browser +``` + +### Requirements Section +Dependencies for build and runtime: + +```yaml +requirements: + host: + - python >=3.9 + - pip + - setuptools + run: + - python >=3.9 + - brightway2 >=2.4 + - pyside6 >=6.0 + - qtpy >=2.0 + # ... more dependencies +``` + +### Test Section +Basic tests to verify package installation: +```yaml +test: + imports: + - activity_browser + commands: + - activity-browser --help +``` + +### About Section +Package metadata: +```yaml +about: + home: https://github.com/LCA-ActivityBrowser/activity-browser + license: LGPL-3.0 + summary: GUI for Brightway2 LCA framework + description: Activity Browser is a GUI for the Brightway2 LCA framework + doc_url: https://lca-activitybrowser.github.io/activity-browser/ +``` + +## Building Locally + +### Prerequisites +- conda-build installed: `conda install conda-build` +- Conda environment set up + +### Build Command +```bash +conda build recipe/ +``` + +This will: +1. Create a clean build environment +2. Install dependencies +3. Build the package from source +4. Run tests +5. Create a conda package (.tar.bz2) + +### Build Variants +For different Python versions: +```bash +conda build recipe/ --python 3.9 +conda build recipe/ --python 3.10 +conda build recipe/ --python 3.11 +``` + +## conda-forge + +Activity Browser is distributed via conda-forge, the community-led conda package repository. + +### conda-forge Repository +The conda-forge recipe is maintained in a separate repository: +https://github.com/conda-forge/activity-browser-feedstock + +### Update Process +When a new version is released: +1. conda-forge bot detects new GitHub release +2. Opens PR to update version and SHA256 +3. Maintainers review and merge +4. Package is built for all platforms +5. Published to conda-forge channel + +### Maintainers +conda-forge package maintainers can: +- Update the recipe +- Adjust dependencies +- Fix build issues +- Release new versions + +## Installation + +Users install from conda-forge: +```bash +conda install -c conda-forge activity-browser +``` + +Or with mamba (faster): +```bash +mamba install -c conda-forge activity-browser +``` + +## Testing the Package + +After building, test the package: + +```bash +# Install from local build +conda install --use-local activity-browser + +# Test entry point +activity-browser --version + +# Launch application +activity-browser +``` + +## Dependencies + +Keep dependencies in sync: +- `meta.yaml` (conda recipe) +- `pyproject.toml` (pip/setuptools) +- `setup.py` (legacy setup) + +Ensure all three specify the same dependencies and versions. + +## Platform Support + +Activity Browser supports: +- **Linux** - x86_64, aarch64 +- **macOS** - x86_64, arm64 (Apple Silicon) +- **Windows** - x86_64 + +The recipe should specify `noarch: python` if the package is pure Python, or include platform-specific builds if needed. + +## Troubleshooting + +### Build Failures +- Check dependency versions +- Verify source path/URL +- Review build logs +- Test in clean environment + +### Import Errors +- Missing dependencies in run requirements +- Incorrect entry points +- Module import issues + +### Test Failures +- Tests timing out +- Missing test dependencies +- Platform-specific issues + +## Development Workflow + +1. **Local Development** + - Edit source code + - Test locally with `python -m activity_browser` + +2. **Update Recipe** + - Modify `meta.yaml` if dependencies changed + - Update version number + +3. **Build and Test** + - Run `conda build recipe/` + - Install and test locally + +4. **Release** + - Tag release on GitHub + - conda-forge bot updates feedstock + - Package published automatically + +## Resources + +- [conda-build documentation](https://docs.conda.io/projects/conda-build/) +- [conda-forge documentation](https://conda-forge.org/docs/) +- [Activity Browser feedstock](https://github.com/conda-forge/activity-browser-feedstock) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..cb1e925da --- /dev/null +++ b/tests/README.md @@ -0,0 +1,322 @@ +# tests + +Test suite for Activity Browser. + +## Overview + +This directory contains the test suite for Activity Browser using pytest. Tests verify functionality, catch regressions, and ensure code quality across the application. + +## Test Framework + +**pytest** is used as the test runner with extensions: +- **pytest-qt** - Testing Qt applications +- **pytest-cov** - Coverage reporting +- **pytest-mock** - Mocking utilities + +## Directory Structure + +- **`actions/`** - Tests for action classes +- **`fixtures/`** - Test fixtures and mock data +- **`widgets/`** - Tests for UI widgets +- Additional test files for various modules + +## Key Files + +- **`conftest.py`** - Pytest configuration and shared fixtures +- **`test_search.py`** - Search engine tests + +## Running Tests + +### Run All Tests +```bash +pytest +``` + +### Run Specific Test File +```bash +pytest tests/test_search.py +``` + +### Run Specific Test +```bash +pytest tests/test_search.py::test_search_basic +``` + +### Run with Coverage +```bash +pytest --cov=activity_browser --cov-report=html +``` + +### Run in Parallel +```bash +pytest -n auto +``` + +## Test Categories + +### Unit Tests +Test individual functions and classes in isolation: +```python +def test_function(): + result = my_function(input_data) + assert result == expected_output +``` + +### Integration Tests +Test interaction between components: +```python +def test_database_import(): + # Test full import workflow + importer.load_file(test_file) + assert database_exists("test_db") +``` + +### UI Tests +Test Qt widgets and interactions: +```python +def test_button_click(qtbot): + widget = MyWidget() + qtbot.addWidget(widget) + qtbot.mouseClick(widget.button, Qt.LeftButton) + assert widget.clicked is True +``` + +### Action Tests +Test action classes: +```python +def test_delete_action(): + DeleteAction.run(item_key) + assert not item_exists(item_key) +``` + +## Fixtures + +Fixtures provide test data and setup (see `conftest.py` and `fixtures/`): + +### Common Fixtures +```python +@pytest.fixture +def sample_activity(): + """Provide a sample activity for testing.""" + return { + "name": "Test Activity", + "unit": "kg", + "location": "GLO" + } + +def test_with_fixture(sample_activity): + # Use fixture + assert sample_activity["unit"] == "kg" +``` + +### Brightway Fixtures +```python +@pytest.fixture +def bw_project(tmp_path): + """Create temporary Brightway project.""" + bd.projects.set_current("test_project") + yield + bd.projects.delete_project("test_project", delete_dir=True) +``` + +### Qt Fixtures +```python +@pytest.fixture +def qtbot(qtbot): + """Pytest-qt bot for widget testing.""" + return qtbot +``` + +## Writing Tests + +### Test Naming +- Test files: `test_*.py` or `*_test.py` +- Test functions: `test_*` +- Test classes: `Test*` + +### Test Structure +```python +def test_something(): + # Arrange - Set up test data + data = prepare_test_data() + + # Act - Execute the code being tested + result = function_under_test(data) + + # Assert - Verify the result + assert result == expected_value +``` + +### UI Test Example +```python +def test_widget_interaction(qtbot): + # Create widget + widget = MyWidget() + qtbot.addWidget(widget) + + # Simulate user input + qtbot.keyClicks(widget.input_field, "test text") + qtbot.mouseClick(widget.submit_button, Qt.LeftButton) + + # Verify result + assert widget.result_label.text() == "Success" +``` + +### Action Test Example +```python +def test_create_database_action(bw_project): + # Setup + db_name = "test_database" + + # Execute action + CreateDatabaseAction.run(db_name) + + # Verify + assert db_name in bd.databases +``` + +## Mocking + +Use mocks to isolate tests: + +```python +from unittest.mock import Mock, patch + +def test_with_mock(mocker): + # Mock external dependency + mock_api = mocker.patch("module.api_call") + mock_api.return_value = {"status": "success"} + + # Test code + result = my_function() + + # Verify mock was called + mock_api.assert_called_once() + assert result["status"] == "success" +``` + +## Testing Signals + +Test Qt signals and slots: + +```python +def test_signal_emission(qtbot): + widget = MyWidget() + + # Use signal spy + with qtbot.waitSignal(widget.data_changed, timeout=1000): + widget.modify_data() + + # Signal was emitted +``` + +## Testing Threads + +Test background operations: + +```python +def test_threaded_operation(qtbot): + widget = MyWidget() + + # Wait for thread to complete + with qtbot.waitSignal(widget.operation_complete, timeout=5000): + widget.start_operation() + + assert widget.result is not None +``` + +## Test Coverage + +Aim for high coverage: +- **Critical paths** - 100% coverage +- **Business logic** - >90% coverage +- **UI code** - >70% coverage +- **Utilities** - >80% coverage + +View coverage report: +```bash +pytest --cov=activity_browser --cov-report=html +open htmlcov/index.html +``` + +## Continuous Integration + +Tests run automatically on: +- Pull requests +- Commits to main branch +- Scheduled runs + +See `.github/workflows/main.yaml` for CI configuration. + +## Development Guidelines + +When writing tests: + +1. **Test behavior, not implementation** - Test what, not how +2. **One assertion per test** - Or at least one logical check +3. **Descriptive names** - Test names should explain what they test +4. **Independent tests** - Tests should not depend on each other +5. **Fast tests** - Keep tests quick (mock slow operations) +6. **Readable tests** - Tests are documentation +7. **Test edge cases** - Not just happy paths +8. **Use fixtures** - Reuse common setup +9. **Mock external dependencies** - Don't rely on network, files, etc. +10. **Clean up** - Use fixtures or teardown to clean up + +## Debugging Tests + +### Run with output +```bash +pytest -s # Show print statements +pytest -v # Verbose output +pytest -vv # Very verbose +``` + +### Run single test with debugger +```bash +pytest --pdb tests/test_file.py::test_function +``` + +### Show test durations +```bash +pytest --durations=10 # Slowest 10 tests +``` + +## Test Organization + +Group related tests: + +```python +class TestDatabaseOperations: + def test_create_database(self): + pass + + def test_delete_database(self): + pass + + def test_copy_database(self): + pass +``` + +Use parametrize for similar tests: + +```python +@pytest.mark.parametrize("input,expected", [ + (1, 2), + (2, 4), + (3, 6), +]) +def test_double(input, expected): + assert double(input) == expected +``` + +## Best Practices + +- **Test first** - Write tests before or alongside code +- **Small tests** - Each test should verify one thing +- **Clear assertions** - Make expected values obvious +- **No logic in tests** - Tests should be straightforward +- **Fail fast** - Catch issues early in the test +- **Document complex tests** - Add comments for clarity +- **Keep tests updated** - Refactor tests with code +- **Review test failures** - Don't ignore failing tests From 15b59cc972e91ba2cfcbc76e8ae1de3db3ec7e2a Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 09:17:11 +0100 Subject: [PATCH 219/267] Fixing tests --- activity_browser/bwutils/metadata/loader.py | 2 +- activity_browser/bwutils/metadata/updater.py | 9 +++++---- tests/conftest.py | 8 +++++++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 68cc423a0..945144eb3 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -172,7 +172,7 @@ def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows, adding to metadatastore {id(self.mds)}") df = self.mds.dataframe - self._fix_categories(secondary_df, df) + # self._fix_categories(secondary_df, df) df = secondary_df.combine_first(df) self.mds.dataframe = df diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index 46268c702..ad1f30226 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -82,7 +82,7 @@ def modify_node(self, ds: pd.Series): self.mds.dataframe = df self.mds.register_mutation(ds.key, "update") - if not hasattr(self.mds, "searcher") and self.mds.searcher is not None: + if not hasattr(self.mds, "searcher") or self.mds.searcher is None: return search_engine_cols = list(set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns @@ -98,7 +98,7 @@ def add_node(self, ds: pd.Series): self.mds.dataframe = df self.mds.register_mutation(ds.key, "add") - if not hasattr(self.mds, "searcher") and self.mds.searcher is not None: + if not hasattr(self.mds, "searcher") or self.mds.searcher is None: return search_engine_cols = list(set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns @@ -109,10 +109,11 @@ def delete_node(self, ds: pd.Series): self.mds.dataframe = self.mds.dataframe.drop(ds.key) self.mds.register_mutation(ds.key, "delete") - if not hasattr(self.mds, "searcher") and self.mds.searcher is not None: + if not hasattr(self.mds, "searcher") or self.mds.searcher is None: return id = ds["id"] + self.mds.searcher.remove_identifier(identifier=id) self.mds.searcher.reset_all_caches(ds["database"]) @@ -131,7 +132,7 @@ def delete_database(self, db_name: str): self.mds.dataframe = self.mds.dataframe.drop(db_name, level=0) - if not hasattr(self.mds, "searcher") and self.mds.searcher is not None: + if not hasattr(self.mds, "searcher") or self.mds.searcher is None: return for id in ids: diff --git a/tests/conftest.py b/tests/conftest.py index 02262a628..017466b6e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,6 +55,12 @@ def basic_database(qapp, main_window): qapp.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + i = 0 + while metadata.loader.secondary_status != "done" and i < 60: + logger.warning("Waiting for primary project load to finish") + time.sleep(1) + i += 1 + db = bf.FunctionalSQLiteDatabase("basic") db.write(deepcopy(DATABASE), process=False) db.metadata["dirty"] = True @@ -69,7 +75,7 @@ def basic_database(qapp, main_window): i = 0 while metadata.loader.secondary_status != "done" and i < 60: - logger.warning("Waiting for metadata loader to finish...") + logger.warning("Waiting for database load to finish...") time.sleep(1) i += 1 From aa398c1d4951ceffc0eb82d7504276a58b5c0a89 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 10:34:20 +0100 Subject: [PATCH 220/267] Fixing tests --- activity_browser/app/__init__.py | 2 +- activity_browser/bwutils/metadata/loader.py | 48 +++++++++++-------- activity_browser/bwutils/metadata/metadata.py | 12 +++-- activity_browser/bwutils/metadata/updater.py | 6 ++- tests/conftest.py | 4 +- 5 files changed, 46 insertions(+), 26 deletions(-) diff --git a/activity_browser/app/__init__.py b/activity_browser/app/__init__.py index 144e33319..275cdb6b2 100644 --- a/activity_browser/app/__init__.py +++ b/activity_browser/app/__init__.py @@ -9,7 +9,7 @@ from .main import MainWindow application = ABApplication() -metadata = MetaDataStore() +metadata = MetaDataStore(application) settings = Settings() # modules dependent on application instance diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 945144eb3..268e06eca 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -1,6 +1,5 @@ import sqlite3 import pickle -import threading import os from multiprocessing import Pool from loguru import logger @@ -8,17 +7,21 @@ import pandas as pd +from qtpy.QtCore import QObject, QThread, Signal, SignalInstance + from .metadata import MetaDataStore from .fields import secondary_types, primary, secondary, search_engine_whitelist, all_fields -class MDSLoader: +class MDSLoader(QObject): primary_status: Literal["idle", "loading", "done"] = "idle" secondary_status: Literal["idle", "loading", "done"] = "idle" def __init__(self, mds: MetaDataStore): + super().__init__(parent=mds) + self.mds = mds - self.thread: threading.Thread | None = None + self.thread: QThread | None = None self.connect_signals() def connect_signals(self): @@ -48,8 +51,9 @@ def load_project(self): self.thread = SecondaryLoadThread( databases=list(bd.databases), sqlite_db=str(sqlite3_lci_db._filepath), - callback=self.secondary_load_project + parent=self, ) + self.thread.result.connect(self.secondary_load_project) self.thread.start() # load primary metadata in the main thread @@ -78,7 +82,8 @@ def cache_load_project(self): self.primary_status = "done" self.secondary_status = "done" - self.thread = threading.Thread(target=self._init_searcher) + self.thread = QThread(parent=self) + self.thread.run = self._init_searcher self.thread.start() def primary_load_project(self): @@ -100,6 +105,7 @@ def primary_load_project(self): self.primary_status = "done" def secondary_load_project(self, secondary_df: pd.DataFrame, sqlite_db: str): + logger.debug("secondary_load_project") from bw2data.backends import sqlite3_lci_db if sqlite_db != str(sqlite3_lci_db._filepath): @@ -122,16 +128,17 @@ def load_database(self, database_name: str): self.primary_status = "loading" self.secondary_status = "loading" - if self.thread is not None and self.thread.is_alive(): + if self.thread is not None and self.thread.isRunning(): logger.debug("Waiting for previous loading thread to finish") - self.thread.join() + self.thread.wait() # start loading thread for secondary metadata self.thread = SecondaryLoadThread( databases=[database_name], sqlite_db=str(sqlite3_lci_db._filepath), - callback=self.secondary_load_database + parent=self, ) + self.thread.result.connect(self.secondary_load_database) self.thread.start() # load primary metadata in the main thread @@ -157,6 +164,7 @@ def primary_load_database(self, database_name: str): def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): from bw2data.backends import sqlite3_lci_db + logger.debug("Starting secondary metadata load database callback") if secondary_df.empty or sqlite_db != str(sqlite3_lci_db._filepath): self.secondary_status = "done" @@ -270,36 +278,38 @@ def _cache_check(self, cached_df: pd.DataFrame) -> bool: -class SecondaryLoadThread(threading.Thread): +class SecondaryLoadThread(QThread): """Thread for loading secondary metadata using multiprocessing Pool.""" + result: SignalInstance = Signal(pd.DataFrame, str) - def __init__(self, databases: list[str], sqlite_db: str, callback: Callable): - super().__init__(daemon=True) + def __init__(self, databases: list[str], sqlite_db: str, parent): + super().__init__(parent=parent) self.databases = databases self.sqlite_db = sqlite_db - self.callback = callback def run(self): """Execute the loading in a background thread.""" try: logger.debug("Starting secondary metadata load with multiprocessing Pool") - with Pool() as pool: - args = [(self.sqlite_db, db, secondary) for db in self.databases] - results = pool.starmap(load, args) + # with Pool() as pool: + # args = [(self.sqlite_db, db, secondary) for db in self.databases] + # results = pool.starmap(load, args) + + results = [load(self.sqlite_db, db, secondary) for db in self.databases] full_df = pd.DataFrame() for df in results: if df is None or df.empty: continue full_df = pd.concat([full_df, df]) - - # Store result and call callback - self.callback(full_df, self.sqlite_db) except Exception as e: logger.error(f"Error loading secondary metadata: {e}", exc_info=True) # Call callback with empty dataframe on error - self.callback(pd.DataFrame(), self.sqlite_db) + full_df = pd.DataFrame() + + logger.debug("Secondary metadata load complete, emitting result signal") + self.result.emit(full_df, self.sqlite_db) def load(fp: str, database_name: str, fields: list[str]): diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 90570626e..4378448b0 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -2,21 +2,24 @@ from loguru import logger from threading import RLock +from qtpy.QtCore import QObject + import pandas as pd from .fields import all_fields, all_types -class MetaDataStore: +class MetaDataStore(QObject): + """Singleton class to manage metadata storage, loading, updating, and searching.""" _instance = None - def __new__(cls): + def __new__(cls, *args, **kwargs): if cls._instance is None: - cls._instance = super().__new__(cls) + cls._instance = super().__new__(cls, *args, **kwargs) cls._instance._initialized = False return cls._instance - def __init__(self): + def __init__(self, parent=None): from .loader import MDSLoader from .updater import MDSUpdater from .searcher import MDSSearcher @@ -26,6 +29,7 @@ def __init__(self): if self._initialized: return self._initialized = True + super().__init__(parent=parent) self._dataframe = pd.DataFrame() self._df_lock = RLock() diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index ad1f30226..facee345c 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -3,12 +3,16 @@ import pandas as pd import numpy as np +from qtpy.QtCore import QObject + from .metadata import MetaDataStore from .fields import primary, secondary, all_types, search_engine_whitelist -class MDSUpdater: +class MDSUpdater(QObject): + def __init__(self, mds: MetaDataStore): + super().__init__(parent=mds) self.mds = mds self.connect_signals() diff --git a/tests/conftest.py b/tests/conftest.py index 017466b6e..7939d4477 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,8 +57,9 @@ def basic_database(qapp, main_window): i = 0 while metadata.loader.secondary_status != "done" and i < 60: - logger.warning("Waiting for primary project load to finish") + logger.warning("Waiting for project load to finish") time.sleep(1) + qapp.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) i += 1 db = bf.FunctionalSQLiteDatabase("basic") @@ -77,6 +78,7 @@ def basic_database(qapp, main_window): while metadata.loader.secondary_status != "done" and i < 60: logger.warning("Waiting for database load to finish...") time.sleep(1) + qapp.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) i += 1 if i >= 60: From 6c2f4a0bcd5ca12b359262764595dd20c65f21a9 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 11:09:36 +0100 Subject: [PATCH 221/267] Cleaning up the mds --- activity_browser/bwutils/metadata/loader.py | 95 ++++++++++--------- activity_browser/bwutils/metadata/metadata.py | 59 ++++-------- activity_browser/bwutils/metadata/updater.py | 14 +-- 3 files changed, 79 insertions(+), 89 deletions(-) diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 268e06eca..7992aeef4 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -3,8 +3,7 @@ import os from multiprocessing import Pool from loguru import logger -from typing import Literal, Callable - +from typing import Literal import pandas as pd from qtpy.QtCore import QObject, QThread, Signal, SignalInstance @@ -82,9 +81,8 @@ def cache_load_project(self): self.primary_status = "done" self.secondary_status = "done" - self.thread = QThread(parent=self) - self.thread.run = self._init_searcher - self.thread.start() + searcher_thread = InitSearcherThread(self.mds, parent=self) + searcher_thread.start() def primary_load_project(self): from bw2data.backends import sqlite3_lci_db @@ -121,7 +119,9 @@ def secondary_load_project(self, secondary_df: pd.DataFrame, sqlite_db: str): self.mds.register_mutation(idx, "update") self.secondary_status = "done" - self._init_searcher() + + searcher_thread = InitSearcherThread(self.mds, parent=self) + searcher_thread.start() def load_database(self, database_name: str): from bw2data.backends import sqlite3_lci_db @@ -180,14 +180,14 @@ def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows, adding to metadatastore {id(self.mds)}") df = self.mds.dataframe - # self._fix_categories(secondary_df, df) + self._fix_categories(secondary_df, df) df = secondary_df.combine_first(df) self.mds.dataframe = df for idx in secondary_df.index: self.mds.register_mutation(idx, "update") - if hasattr(self.mds, "searcher") and self.mds.searcher is not None: + if self.mds.searcher is not None: search_engine_cols = list(set(all_fields) & set(search_engine_whitelist)) df = self.mds.get_database_metadata(database, search_engine_cols) for col in df.select_dtypes(include=['category']).columns: @@ -208,33 +208,6 @@ def _fix_categories(df: pd.DataFrame, mds_df: pd.DataFrame): # add new category to column mds_df[col] = mds_df[col].cat.add_categories(categories) - def _init_searcher(self): - from .searcher import MDSSearcher - - if os.environ.get("AB_NO_SEARCHER"): - logger.debug("Skipping searcher initialization due to AB_NO_SEARCHER environment variable") - return - - if hasattr(self.mds, 'searcher') and self.mds.searcher is not None: - old_searcher = self.mds.searcher - self.mds.searcher = None - - # Clear large data structures - if hasattr(old_searcher, 'df'): - del old_searcher.df - if hasattr(old_searcher, 'identifier_to_word'): - del old_searcher.identifier_to_word - if hasattr(old_searcher, 'word_to_identifier'): - del old_searcher.word_to_identifier - if hasattr(old_searcher, 'word_to_q_grams'): - del old_searcher.word_to_q_grams - if hasattr(old_searcher, 'q_gram_to_word'): - del old_searcher.q_gram_to_word - - del old_searcher - - self.mds.searcher = MDSSearcher(self.mds) - def _has_cache(self) -> bool: from activity_browser.bwutils import filesystem @@ -278,6 +251,42 @@ def _cache_check(self, cached_df: pd.DataFrame) -> bool: +class InitSearcherThread(QThread): + """Thread for initializing the searcher.""" + + def __init__(self, mds: MetaDataStore, parent): + super().__init__(parent=parent) + self.mds = mds + + def run(self): + """Execute the searcher initialization in a background thread.""" + from .searcher import MDSSearcher + + if os.environ.get("AB_NO_SEARCHER"): + logger.debug("Skipping searcher initialization due to AB_NO_SEARCHER environment variable") + return + + if self.mds.searcher is not None: + old_searcher = self.mds.searcher + self.mds.searcher = None + + # Clear large data structures + if hasattr(old_searcher, 'df'): + del old_searcher.df + if hasattr(old_searcher, 'identifier_to_word'): + del old_searcher.identifier_to_word + if hasattr(old_searcher, 'word_to_identifier'): + del old_searcher.word_to_identifier + if hasattr(old_searcher, 'word_to_q_grams'): + del old_searcher.word_to_q_grams + if hasattr(old_searcher, 'q_gram_to_word'): + del old_searcher.q_gram_to_word + + del old_searcher + + self.mds.searcher = MDSSearcher(self.mds) + + class SecondaryLoadThread(QThread): """Thread for loading secondary metadata using multiprocessing Pool.""" result: SignalInstance = Signal(pd.DataFrame, str) @@ -290,12 +299,14 @@ def __init__(self, databases: list[str], sqlite_db: str, parent): def run(self): """Execute the loading in a background thread.""" try: - logger.debug("Starting secondary metadata load with multiprocessing Pool") - # with Pool() as pool: - # args = [(self.sqlite_db, db, secondary) for db in self.databases] - # results = pool.starmap(load, args) - - results = [load(self.sqlite_db, db, secondary) for db in self.databases] + if len(self.databases) > 1: + logger.debug(f"Loading metadata from {len(self.databases)} databases using multiprocessing Pool") + with Pool() as pool: + args = [(self.sqlite_db, db, secondary) for db in self.databases] + results = pool.starmap(load, args) + else: + logger.debug("Loading metadata from a single database without multiprocessing") + results = [load(self.sqlite_db, db, secondary) for db in self.databases] full_df = pd.DataFrame() for df in results: @@ -305,10 +316,8 @@ def run(self): except Exception as e: logger.error(f"Error loading secondary metadata: {e}", exc_info=True) - # Call callback with empty dataframe on error full_df = pd.DataFrame() - logger.debug("Secondary metadata load complete, emitting result signal") self.result.emit(full_df, self.sqlite_db) diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 4378448b0..a52b87671 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -1,6 +1,5 @@ from typing import Literal, Optional from loguru import logger -from threading import RLock from qtpy.QtCore import QObject @@ -32,7 +31,6 @@ def __init__(self, parent=None): super().__init__(parent=parent) self._dataframe = pd.DataFrame() - self._df_lock = RLock() self._added: set[tuple[str, str]] = set() self._updated: set[tuple[str, str]] = set() @@ -44,16 +42,10 @@ def __init__(self, parent=None): @property def dataframe(self) -> pd.DataFrame: - with self._df_lock: - copy = self._dataframe.copy(deep=True) - return copy + return self._dataframe @dataframe.setter def dataframe(self, df: pd.DataFrame) -> None: - # Perform all transformations outside the lock - # Make a full copy to avoid any shared memory with the input - df = df.copy(deep=True) - # Ensure all expected columns are present, in the correct order df = df.reindex(columns=all_fields)[all_fields] @@ -68,21 +60,15 @@ def dataframe(self, df: pd.DataFrame) -> None: continue df[col] = df[col].where(df[col].notnull(), None) - # Set the internal dataframe under lock - with self._df_lock: - self._dataframe = df + self._dataframe = df @property def databases(self): - with self._df_lock: - databases = set(self._dataframe.index.get_level_values(0).unique().tolist()) - return databases + return set(self._dataframe.index.get_level_values(0).unique().tolist()) @property def keys(self): - with self._df_lock: - keys = set(self._dataframe.index.tolist()) - return keys + return set(self._dataframe.index.tolist()) def register_mutation(self, key: tuple[str, str], action: Literal["add", "update", "delete"]): if action == "add": @@ -118,8 +104,7 @@ def flush_mutations(self) -> tuple[set[tuple[str, str]], set[tuple[str, str]], s self._deleted.clear() cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" - with self._df_lock: - self._dataframe.to_pickle(cache_path) + self._dataframe.to_pickle(cache_path) return added, updated, deleted @@ -133,7 +118,7 @@ def match(self, **kwargs: dict[str, str]) -> pd.DataFrame: f"`{key}`.astype('str') == {str(value)!r}" if not pd.isna(value) else f"`{key}`.isnull()" for key, value in kwargs.items() ]) - ).copy(deep=True) + ) return df @@ -147,8 +132,7 @@ def get_metadata(self, keys: list = None, columns: list = None) -> pd.DataFrame: keys = keys if keys is not None else self._dataframe.index.tolist() columns = columns if columns is not None else all_fields - with self._df_lock: - df = self._dataframe.loc[pd.IndexSlice[keys], :].copy(deep=True) + df = self._dataframe.loc[pd.IndexSlice[keys], :] return df.reindex(columns, axis="columns") def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFrame: @@ -157,8 +141,7 @@ def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFr if db_name not in self.databases: return pd.DataFrame(columns=columns or all_fields) - with self._df_lock: - df = self._dataframe.loc[[db_name], columns].copy(deep=True) + df = self._dataframe.loc[[db_name], columns] return df.reindex(columns, axis="columns") def search(self, query: str, columns: list = None) -> pd.DataFrame: @@ -180,20 +163,18 @@ def search_database(self, query: str, database: str, columns: list = None) -> pd return self._meta_from_result(params, result, columns) def _meta_from_result(self, params: dict, result: list[int], columns: list = None) -> pd.DataFrame: - with self._df_lock: - df = self._dataframe.loc[self.dataframe["id"].isin(result), columns or all_fields] - df.sort_values(by="id", inplace=True, key=lambda x: x.map({id_: i for i, id_ in enumerate(result)})) - - extra_query = " & ".join( - [ - f"`{key}`.astype('str').str.contains('{value}', False)" - for key, value in params.items() - if key in df.columns - ] - ) - if extra_query: - df = df.query(extra_query) - df = df.copy(deep=True) + df = self._dataframe.loc[self.dataframe["id"].isin(result), columns or all_fields] + df.sort_values(by="id", inplace=True, key=lambda x: x.map({id_: i for i, id_ in enumerate(result)})) + + extra_query = " & ".join( + [ + f"`{key}`.astype('str').str.contains('{value}', False)" + for key, value in params.items() + if key in df.columns + ] + ) + if extra_query: + df = df.query(extra_query) return df diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index facee345c..a0fff878f 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -102,7 +102,7 @@ def add_node(self, ds: pd.Series): self.mds.dataframe = df self.mds.register_mutation(ds.key, "add") - if not hasattr(self.mds, "searcher") or self.mds.searcher is None: + if self.mds.searcher is None: return search_engine_cols = list(set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns @@ -113,12 +113,12 @@ def delete_node(self, ds: pd.Series): self.mds.dataframe = self.mds.dataframe.drop(ds.key) self.mds.register_mutation(ds.key, "delete") - if not hasattr(self.mds, "searcher") or self.mds.searcher is None: + if self.mds.searcher is None: return - id = ds["id"] + node_id = ds["id"] - self.mds.searcher.remove_identifier(identifier=id) + self.mds.searcher.remove_identifier(identifier=node_id) self.mds.searcher.reset_all_caches(ds["database"]) # database methods @@ -136,11 +136,11 @@ def delete_database(self, db_name: str): self.mds.dataframe = self.mds.dataframe.drop(db_name, level=0) - if not hasattr(self.mds, "searcher") or self.mds.searcher is None: + if self.mds.searcher is None: return - for id in ids: - self.mds.searcher.remove_identifier(identifier=id) + for node_id in ids: + self.mds.searcher.remove_identifier(identifier=node_id) self.mds.searcher.reset_all_caches(db_name) # utility functions From 24842ac6b272045fb5195e490ae2c15ef74a8b67 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 11:26:55 +0100 Subject: [PATCH 222/267] Add SYNC log level --- activity_browser/__main__.py | 6 +++++- .../app/dialogs/import_preview_dialog/edge_tab.py | 2 +- .../app/dialogs/import_preview_dialog/node_tab.py | 2 +- activity_browser/app/main.py | 2 +- activity_browser/app/menu_bar.py | 2 +- .../app/pages/activity_details/activity_details.py | 2 +- .../app/pages/activity_details/activity_header.py | 2 +- .../app/pages/activity_details/consumers_tab.py | 2 +- activity_browser/app/pages/activity_details/data_tab.py | 2 +- .../app/pages/activity_details/description_tab.py | 2 +- .../app/pages/activity_details/exchanges_tab.py | 2 +- activity_browser/app/pages/activity_details/graph_tab.py | 2 +- .../app/pages/activity_details/parameters_tab.py | 2 +- .../app/pages/calculation_setup/calculation_setup.py | 2 +- .../app/pages/calculation_setup/functional_unit_section.py | 2 +- .../app/pages/calculation_setup/impact_category_section.py | 2 +- .../impact_category_details/impact_category_details.py | 2 +- .../pages/impact_category_details/impact_category_header.py | 6 +++--- activity_browser/app/pages/metadatastore.py | 2 +- .../app/pages/parameters/parameterized_exchanges_section.py | 2 +- activity_browser/app/pages/parameters/parameters_section.py | 2 +- activity_browser/app/pages/settings/project_manager.py | 2 +- activity_browser/app/panes/calculation_setups.py | 2 +- activity_browser/app/panes/database_products.py | 2 +- activity_browser/app/panes/databases.py | 2 +- activity_browser/app/panes/impact_categories.py | 2 +- 26 files changed, 32 insertions(+), 28 deletions(-) diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index 922501cd5..a2f688dee 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -114,8 +114,12 @@ def run(self): def setup_logging(): """Configure loguru sinks for console and file logging.""" + logger.level("SYNC", no=9, color="") + logger.level("TEST", no=19, color="") + + logger.remove() - logger.add(sys.stderr, level="DEBUG", colorize=True, + logger.add(sys.stderr, level=6, colorize=True, format="{time:HH:mm:ss} | {level: <8} | {message}") log_dir = platformdirs.user_log_dir(appname="ActivityBrowser", appauthor="pylca") diff --git a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py index bb9605e68..203863cfa 100644 --- a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py +++ b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py @@ -51,7 +51,7 @@ def __init__(self, importer: LCIImporter, parent=None): def sync(self): """Synchronize the view based on simple/detailed mode.""" - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.edge_view.header().setHidden(self.simple) self.edge_view.viewport().setBackgroundRole( diff --git a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py index 6afe7fbff..de6e72f02 100644 --- a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py +++ b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py @@ -47,7 +47,7 @@ def __init__(self, importer: LCIImporter, parent=None): def sync(self): """Synchronize the view based on simple/detailed mode.""" - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.node_view.header().setHidden(self.simple) self.node_view.viewport().setBackgroundRole( diff --git a/activity_browser/app/main.py b/activity_browser/app/main.py index 98302001b..3c0e99629 100644 --- a/activity_browser/app/main.py +++ b/activity_browser/app/main.py @@ -64,7 +64,7 @@ def event(self, event): return super().event(event) def sync(self): - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.sync_panes() self.sync_pages() diff --git a/activity_browser/app/menu_bar.py b/activity_browser/app/menu_bar.py index 64b86e3c0..46f25507f 100644 --- a/activity_browser/app/menu_bar.py +++ b/activity_browser/app/menu_bar.py @@ -167,7 +167,7 @@ def __init__(self, parent=None) -> None: app.signals.meta.calculation_setups_changed.connect(self.sync) def sync(self): - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.cs_actions.clear() for cs in bd.calculation_setups: diff --git a/activity_browser/app/pages/activity_details/activity_details.py b/activity_browser/app/pages/activity_details/activity_details.py index b7f73f03d..5b2ae11e6 100644 --- a/activity_browser/app/pages/activity_details/activity_details.py +++ b/activity_browser/app/pages/activity_details/activity_details.py @@ -148,7 +148,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.activity = refresh_node_or_none(self.activity) diff --git a/activity_browser/app/pages/activity_details/activity_header.py b/activity_browser/app/pages/activity_details/activity_header.py index 6ac523487..c9828ef82 100644 --- a/activity_browser/app/pages/activity_details/activity_header.py +++ b/activity_browser/app/pages/activity_details/activity_header.py @@ -39,7 +39,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.activity = refresh_node(self.activity) diff --git a/activity_browser/app/pages/activity_details/consumers_tab.py b/activity_browser/app/pages/activity_details/consumers_tab.py index 13d26e9f8..6240fb7e9 100644 --- a/activity_browser/app/pages/activity_details/consumers_tab.py +++ b/activity_browser/app/pages/activity_details/consumers_tab.py @@ -52,7 +52,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.activity = refresh_node(self.activity) exchanges = [] diff --git a/activity_browser/app/pages/activity_details/data_tab.py b/activity_browser/app/pages/activity_details/data_tab.py index 93ebc062f..b9d2da129 100644 --- a/activity_browser/app/pages/activity_details/data_tab.py +++ b/activity_browser/app/pages/activity_details/data_tab.py @@ -56,7 +56,7 @@ def sync(self) -> None: """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.activity = refresh_node(self.activity) df = self.build_df() diff --git a/activity_browser/app/pages/activity_details/description_tab.py b/activity_browser/app/pages/activity_details/description_tab.py index 03a2c7af5..c5605c7bb 100644 --- a/activity_browser/app/pages/activity_details/description_tab.py +++ b/activity_browser/app/pages/activity_details/description_tab.py @@ -30,7 +30,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.activity = refresh_node(self.activity) self.setText(self.activity.get("comment", "")) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index 91690945b..d6a6a9ec1 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -97,7 +97,7 @@ def sync(self) -> None: """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") # Refresh the activity node self.activity = refresh_node(self.activity) diff --git a/activity_browser/app/pages/activity_details/graph_tab.py b/activity_browser/app/pages/activity_details/graph_tab.py index cd3bb1063..8b44a7f86 100644 --- a/activity_browser/app/pages/activity_details/graph_tab.py +++ b/activity_browser/app/pages/activity_details/graph_tab.py @@ -73,7 +73,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.activity = refresh_node(self.activity) json = self.build_json() diff --git a/activity_browser/app/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py index 1605f3ab0..c8fbe6395 100644 --- a/activity_browser/app/pages/activity_details/parameters_tab.py +++ b/activity_browser/app/pages/activity_details/parameters_tab.py @@ -59,7 +59,7 @@ def sync(self): """ Synchronizes the widget with the current state of the activity. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.activity = refresh_node(self.activity) df = self.build_df() diff --git a/activity_browser/app/pages/calculation_setup/calculation_setup.py b/activity_browser/app/pages/calculation_setup/calculation_setup.py index 1bc590923..17e2dc0f6 100644 --- a/activity_browser/app/pages/calculation_setup/calculation_setup.py +++ b/activity_browser/app/pages/calculation_setup/calculation_setup.py @@ -71,7 +71,7 @@ def connect_signals(self): self.run_button.released.connect(self.run_calculation) def sync(self) -> None: - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.functional_unit_section.sync() self.impact_category_section.sync() diff --git a/activity_browser/app/pages/calculation_setup/functional_unit_section.py b/activity_browser/app/pages/calculation_setup/functional_unit_section.py index cbaed45d1..2a0c6145a 100644 --- a/activity_browser/app/pages/calculation_setup/functional_unit_section.py +++ b/activity_browser/app/pages/calculation_setup/functional_unit_section.py @@ -29,7 +29,7 @@ def build_layout(self): self.setLayout(layout) def sync(self): - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") try: self.calculation_setup = bd.calculation_setups[self.calculation_setup_name] diff --git a/activity_browser/app/pages/calculation_setup/impact_category_section.py b/activity_browser/app/pages/calculation_setup/impact_category_section.py index 1704ccd3d..d3119a331 100644 --- a/activity_browser/app/pages/calculation_setup/impact_category_section.py +++ b/activity_browser/app/pages/calculation_setup/impact_category_section.py @@ -27,7 +27,7 @@ def build_layout(self): self.setLayout(layout) def sync(self): - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") try: self.calculation_setup = bd.calculation_setups[self.calculation_setup_name] diff --git a/activity_browser/app/pages/impact_category_details/impact_category_details.py b/activity_browser/app/pages/impact_category_details/impact_category_details.py index 49a29fc64..e46fd9537 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_details.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_details.py @@ -47,7 +47,7 @@ def on_method_deleted(self, method): self.deleteLater() def sync(self): - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") if self.name not in bd.methods: self.deleteLater() diff --git a/activity_browser/app/pages/impact_category_details/impact_category_header.py b/activity_browser/app/pages/impact_category_details/impact_category_header.py index bc9db93f7..492728e7c 100644 --- a/activity_browser/app/pages/impact_category_details/impact_category_header.py +++ b/activity_browser/app/pages/impact_category_details/impact_category_header.py @@ -44,7 +44,7 @@ def sync(self): Synchronizes the widget with the current state of the impact category. Switches between editable and view-only headers based on edit mode. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.impact_category = self.parent().impact_category @@ -105,7 +105,7 @@ def sync(self): """ Updates the displayed information from the current impact category. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") impact_category = self.parent().impact_category self.name_label.setText(" | ".join(impact_category.name)) @@ -151,7 +151,7 @@ def sync(self): """ Updates the displayed information from the current impact category. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") impact_category = self.parent().impact_category self.name_label.setText(f"{' | '.join(impact_category.name)}") diff --git a/activity_browser/app/pages/metadatastore.py b/activity_browser/app/pages/metadatastore.py index 2909e8bee..b1336b260 100644 --- a/activity_browser/app/pages/metadatastore.py +++ b/activity_browser/app/pages/metadatastore.py @@ -21,7 +21,7 @@ def connect_signals(self): signals.metadata.synced.connect(self.sync) def sync(self): - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") self.model.set_dataframe(metadata.dataframe) def build_layout(self): diff --git a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py index d5f37d373..ef9ee22be 100644 --- a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py +++ b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py @@ -64,7 +64,7 @@ def sync(self): """ Synchronizes the widget with the current state of parameterized exchanges. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") df = self.build_exchanges_df() self.model.set_dataframe(df) diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py index 86de24463..8eff88c50 100644 --- a/activity_browser/app/pages/parameters/parameters_section.py +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -68,7 +68,7 @@ def sync(self): """ Synchronizes the widget with the current state of parameters. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") df = self.build_df() self.model.set_dataframe(df, group=["_param_type", "_scope"]) diff --git a/activity_browser/app/pages/settings/project_manager.py b/activity_browser/app/pages/settings/project_manager.py index a4aa2abee..925550825 100644 --- a/activity_browser/app/pages/settings/project_manager.py +++ b/activity_browser/app/pages/settings/project_manager.py @@ -49,7 +49,7 @@ def connect_signals(self): def sync(self): """Sync project and template data.""" - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") df = self.build_project_df() self.project_model.set_dataframe(df) diff --git a/activity_browser/app/panes/calculation_setups.py b/activity_browser/app/panes/calculation_setups.py index 65d4442fb..c5efdf014 100644 --- a/activity_browser/app/panes/calculation_setups.py +++ b/activity_browser/app/panes/calculation_setups.py @@ -52,7 +52,7 @@ def sync(self): """ Synchronizes the model with the current state of the calculation setups. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") df = self.build_df() self.model.set_dataframe(df) self.view.resizeColumnToContents(0) diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 0ef44f752..e8a2fe6c1 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -149,7 +149,7 @@ def sync(self): """ Synchronizes the widget with the current state of the database. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") t = time() df = self.build_df() diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py index ae9e15fdf..37135841a 100644 --- a/activity_browser/app/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -66,7 +66,7 @@ def sync(self): """ Synchronizes the model with the current state of the databases. """ - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") df = self.build_df() self.model.set_dataframe(df) diff --git a/activity_browser/app/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py index 7f049c3b3..f909a8581 100644 --- a/activity_browser/app/panes/impact_categories.py +++ b/activity_browser/app/panes/impact_categories.py @@ -45,7 +45,7 @@ def connect_signals(self): app.signals.database_read_only_changed.connect(self.sync) def sync(self): - logger.debug(f"Syncing {self.__class__.__name__}: {id(self)}") + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") df = self.build_df() self.model.set_dataframe(df, group=["_method_name"]) From 8a9bdfc7ea54b46ebc359cb3d2dd27d08c14d6fb Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 11:56:09 +0100 Subject: [PATCH 223/267] Some logging changes --- activity_browser/__main__.py | 2 -- activity_browser/app/actions/project/project_switch.py | 7 ++++--- activity_browser/app/panes/databases.py | 1 - activity_browser/bwutils/metadata/metadata.py | 2 -- activity_browser/bwutils/searchengine/base.py | 2 -- 5 files changed, 4 insertions(+), 10 deletions(-) diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index a2f688dee..077658341 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -95,7 +95,6 @@ def load_layout(self): def load_finished(self): from activity_browser import app - app.main_window.sync() app.main_window.show() self.deleteLater() @@ -151,7 +150,6 @@ def run_activity_browser_no_launcher(): from .ui.widgets import CentralTabWidget from .app import panes, pages, application, metadata - application.main_window.sync() application.main_window.show() application.set_icon() # setting this here seems to fix the icon not showing sometimes diff --git a/activity_browser/app/actions/project/project_switch.py b/activity_browser/app/actions/project/project_switch.py index a573337ab..b6e8abe48 100644 --- a/activity_browser/app/actions/project/project_switch.py +++ b/activity_browser/app/actions/project/project_switch.py @@ -45,13 +45,11 @@ def run(project_name: str): dialog = ProjectChangeDialog(project_name, app.main_window) dialog.show() - app.application.thread().eventDispatcher().processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + app.application.processEvents() # switch to the new project, don't auto update to brightway25 bd.projects.set_current(project_name, update=False) - dialog.close() - if not bd.projects.twofive: logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") ProjectSwitch.set_warning_bar() @@ -62,6 +60,9 @@ def run(project_name: str): bd.projects.dataset.data["last_opened"] = datetime.datetime.now().isoformat() bd.projects.dataset.save() + app.application.processEvents() + dialog.close() + @staticmethod def set_warning_bar(): app.main_window.addToolBar(ProjectWarningBar()) diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py index 37135841a..20a379450 100644 --- a/activity_browser/app/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -31,7 +31,6 @@ def __init__(self, parent): Args: parent (QtWidgets.QWidget): The parent widget. """ - logger.debug(f"Initializing DatabasesPane: {id(self)}") super().__init__(parent) self.model = DatabasesModel(parent=self) self.view = DatabasesView() diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index a52b87671..1d8b92233 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -23,8 +23,6 @@ def __init__(self, parent=None): from .updater import MDSUpdater from .searcher import MDSSearcher - logger.debug(f"Initializing MetaDataStore: {id(self)}") - if self._initialized: return self._initialized = True diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py index d16763a4d..0145e428e 100644 --- a/activity_browser/bwutils/searchengine/base.py +++ b/activity_browser/bwutils/searchengine/base.py @@ -1,7 +1,6 @@ import itertools import functools import re -import sys from collections import Counter, OrderedDict, defaultdict from typing import Iterable, Optional from time import time @@ -183,7 +182,6 @@ def words_in_df(self, df: pd.DataFrame = None) -> tuple[dict, pd.DataFrame]: def reverse_dict_many_to_one(self, dictionary: dict) -> dict: """Reverse a dictionary of Counter objects.""" - logger.debug(f"reverse_dict_many_to_one called with {len(dictionary)} items") reverse = defaultdict(Counter) for identifier, counter_object in dictionary.items(): if not isinstance(counter_object, Counter): From 8c6ae460a71fd95bdc170cd5d45ead7a917d2f7e Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 12:09:58 +0100 Subject: [PATCH 224/267] Remove old settings --- activity_browser/README.md | 4 - activity_browser/app/actions/__init__.py | 1 - .../app/actions/database/database_delete.py | 5 +- .../app/actions/project/project_delete.py | 4 +- .../app/actions/settings_wizard_open.py | 16 - .../app/pages/lca_results/LCA_results.py | 8 +- .../app/pages/lca_results/tables.py | 4 +- activity_browser/bwutils/filesystem.py | 2 +- activity_browser/settings.py | 325 ------------------ activity_browser/ui/web/base.py | 7 +- activity_browser/ui/wizards/README.md | 295 ---------------- activity_browser/ui/wizards/__init__.py | 1 - .../ui/wizards/settings_wizard.py | 316 ----------------- 13 files changed, 12 insertions(+), 976 deletions(-) delete mode 100644 activity_browser/app/actions/settings_wizard_open.py delete mode 100644 activity_browser/settings.py delete mode 100644 activity_browser/ui/wizards/README.md delete mode 100644 activity_browser/ui/wizards/__init__.py delete mode 100644 activity_browser/ui/wizards/settings_wizard.py diff --git a/activity_browser/README.md b/activity_browser/README.md index 6f68b4f15..6850d4586 100644 --- a/activity_browser/README.md +++ b/activity_browser/README.md @@ -20,7 +20,6 @@ Activity Browser is a Qt-based desktop application that provides a GUI front-end - **`__init__.py`** - Package initialization with PySide6/typing compatibility patches - **`__main__.py`** - Entry point for the application (`run_activity_browser` function) - **`info.py`** - Version and application metadata -- **`settings.py`** - Settings management using platformdirs for persistent user/project settings ## Entry Points @@ -35,7 +34,6 @@ All entry points lead to `activity_browser.__main__:run_activity_browser`. The application follows an MVC-like pattern with: - **Global signals** (`activity_browser.app.signals`) - Event bus for cross-component communication -- **Settings persistence** (`ab_settings`, `project_settings`) - JSON-based configuration storage - **Deferred imports** - Heavy modules are loaded in background threads during startup - **Actions pattern** - UI operations encapsulated in `app/actions/` with a base class pattern @@ -44,12 +42,10 @@ The application follows an MVC-like pattern with: Main dependencies include: - **PySide6** (via qtpy) - Qt bindings for the GUI - **Brightway2** ecosystem (bw2data, bw2calc, bw2analyzer, bw2io) - LCA calculation engine -- **platformdirs** - Cross-platform settings directory management - **loguru** - Logging framework ## Development Notes - Avoid top-level imports of heavy modules (PySide6, bw2data) to keep tests fast - Use project signals for cross-component communication instead of direct function calls -- Settings are persisted per-user via platformdirs; use the singleton instances - Global shortcuts are registered via `@application.global_shortcut` decorator diff --git a/activity_browser/app/actions/__init__.py b/activity_browser/app/actions/__init__.py index a105201b0..6ca690aaa 100644 --- a/activity_browser/app/actions/__init__.py +++ b/activity_browser/app/actions/__init__.py @@ -92,7 +92,6 @@ from .project.project_create_template import ProjectCreateTemplate from .project.project_new_template import ProjectNewFromTemplate -from .settings_wizard_open import SettingsWizardOpen from .migrations_install import MigrationsInstall from .pyside_upgrade import PysideUpgrade from .metadatastore_open import MetaDataStoreOpen diff --git a/activity_browser/app/actions/database/database_delete.py b/activity_browser/app/actions/database/database_delete.py index a3cade06d..81af59e79 100644 --- a/activity_browser/app/actions/database/database_delete.py +++ b/activity_browser/app/actions/database/database_delete.py @@ -6,7 +6,7 @@ from bw2data.parameters import Group from bw2data.backends.proxies import ExchangeDataset, Exchanges -from activity_browser import app, settings +from activity_browser import app from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -86,9 +86,6 @@ def run(db_names: List[str]): # delete database parameters Group.delete().where(Group.name == db_name).execute() - # remove database from project settings - settings.project_settings.remove_db(db_name) - QtWidgets.QApplication.restoreOverrideCursor() diff --git a/activity_browser/app/actions/project/project_delete.py b/activity_browser/app/actions/project/project_delete.py index 513b14454..3552f3825 100644 --- a/activity_browser/app/actions/project/project_delete.py +++ b/activity_browser/app/actions/project/project_delete.py @@ -6,7 +6,7 @@ from bw2data.project import ProjectDataset from bw2data.utils import safe_filename -from activity_browser import settings, app +from activity_browser import app from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons @@ -54,7 +54,7 @@ def run(project_names: list[str] = None): return # if it's the startup project: reject deletion and inform user - if settings.ab_settings.startup_project in project_names: + if app.settings["startup"]["startup_project"] in project_names: QtWidgets.QMessageBox.information( app.main_window, "Not possible", diff --git a/activity_browser/app/actions/settings_wizard_open.py b/activity_browser/app/actions/settings_wizard_open.py deleted file mode 100644 index 8118611fc..000000000 --- a/activity_browser/app/actions/settings_wizard_open.py +++ /dev/null @@ -1,16 +0,0 @@ -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons -from activity_browser.ui.wizards.settings_wizard import SettingsWizard - - -class SettingsWizardOpen(ABAction): - """ABAction to open the SettingsWizard""" - - icon = qicons.settings - text = "Settings..." - - @staticmethod - @exception_dialogs - def run(): - SettingsWizard(app.main_window).show() diff --git a/activity_browser/app/pages/lca_results/LCA_results.py b/activity_browser/app/pages/lca_results/LCA_results.py index fb8666cdf..b43ec9045 100644 --- a/activity_browser/app/pages/lca_results/LCA_results.py +++ b/activity_browser/app/pages/lca_results/LCA_results.py @@ -11,7 +11,7 @@ from stats_arrays.errors import InvalidParamsError -from activity_browser import app, settings +from activity_browser import app from activity_browser.bwutils.commontasks import unit_of_method, get_LCIA_method_name_dict, format_activity_label from activity_browser.bwutils.sensitivity_analysis import GlobalSensitivityAnalysis from activity_browser.mod.bw2analyzer import ABContributionAnalysis @@ -330,10 +330,6 @@ def total_check(self, checked: bool): def score_mrk_check(self, checked: bool): self.score_marker = checked - settings.project_settings.settings["analysis_tab"] = settings.project_settings.settings.get("analysis_tab", {}) - settings.project_settings.settings["analysis_tab"][f"{self.__class__.__name__}score_marker_enabled"] = checked - settings.project_settings.write_settings() - self.update_tab() def get_scenario_labels(self) -> List[str]: @@ -967,7 +963,7 @@ def __init__(self, parent, **kwargs): self.total_group.addButton(self.total_menu.score) self.total_group.addButton(self.total_menu.range) - self.score_marker = settings.project_settings.settings.get("analysis_tab", {}).get(f"{self.__class__.__name__}score_marker_enabled", False) + self.score_marker = False self.score_mrk_checkbox = QtWidgets.QCheckBox("Score Marker") self.score_mrk_checkbox.setToolTip( "Shows the score marker. When there are both positive and negative results,\n" diff --git a/activity_browser/app/pages/lca_results/tables.py b/activity_browser/app/pages/lca_results/tables.py index d575ea62a..a44677581 100644 --- a/activity_browser/app/pages/lca_results/tables.py +++ b/activity_browser/app/pages/lca_results/tables.py @@ -12,9 +12,9 @@ from qtpy.QtCore import QPoint, QRect, QSize, Qt, QTimer, Signal, Slot, SignalInstance from qtpy.QtWidgets import QSizePolicy, QTableView -from activity_browser.settings import ab_settings from activity_browser.ui.icons import qicons from activity_browser.ui import delegates +from activity_browser.bwutils import filesystem from .dialogs import FilterManagerDialog, SimpleFilterDialog @@ -517,7 +517,7 @@ def savefilepath( filepath, _ = QtWidgets.QFileDialog.getSaveFileName( parent=self, caption=caption, - dir=str(os.path.join(ab_settings.data_dir, safe_name)), + dir=str(os.path.join(filesystem.get_project_path(), safe_name)), filter=file_filter or self.ALL_FILTER, ) # getSaveFileName can now weirdly return Path objects. diff --git a/activity_browser/bwutils/filesystem.py b/activity_browser/bwutils/filesystem.py index 7dec94291..6fec07a8e 100644 --- a/activity_browser/bwutils/filesystem.py +++ b/activity_browser/bwutils/filesystem.py @@ -5,7 +5,7 @@ def get_package_path() -> Path: - path = Path(__file__).resolve().parents[2] + path = Path(__file__).resolve().parents[1] path.mkdir(parents=True, exist_ok=True) return path diff --git a/activity_browser/settings.py b/activity_browser/settings.py deleted file mode 100644 index 5655c1d3b..000000000 --- a/activity_browser/settings.py +++ /dev/null @@ -1,325 +0,0 @@ -# -*- coding: utf-8 -*- -import json -import os -import shutil -from pathlib import Path -from typing import Optional, Any -from loguru import logger - -import bw2data as bd - -import platformdirs -from qtpy.QtWidgets import QMessageBox - -from .app import signals - - -DEFAULT_BW_DATA_DIR = bd.projects._base_data_dir - - -def pathlib_encoder(value: Any) -> Any: - if isinstance(value, Path): - return str(value) - else: - return value - - -class BaseSettings(object): - """Base Class for handling JSON settings files.""" - - def __init__(self, directory: str, filename: str = None): - self.data_dir = directory - self.filename = filename or "default_settings.json" - self.settings_file = os.path.join(self.data_dir, self.filename) - self.settings: Optional[dict] = None - self.initialize_settings() - - @classmethod - def get_default_settings(cls) -> dict: - """Returns dictionary containing the default settings for the file""" - raise NotImplementedError - - def restore_default_settings(self) -> None: - """Undo all user settings and return to original state.""" - self.settings = self.get_default_settings() - self.write_settings() - - def initialize_settings(self) -> None: - """Attempt to find and read the settings_file, creates a default - if not found - """ - if os.path.isfile(self.settings_file): - self.load_settings() - else: - self.settings = self.get_default_settings() - self.write_settings() - - def load_settings(self) -> None: - with open(self.settings_file, "r") as infile: - self.settings = json.load(infile) - - def write_settings(self) -> None: - with open(self.settings_file, "w") as outfile: - json.dump(self.settings, outfile, indent=4, sort_keys=True, default=pathlib_encoder) - - -class ABSettings(BaseSettings): - """ - Interface to the json settings file. Will create a userdata directory via platformdirs if not - already present. - """ - - def __init__(self, filename: str): - ab_dir = str(platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="ActivityBrowser")) - if not os.path.isdir(ab_dir): - os.makedirs(ab_dir, exist_ok=True) - self.update_old_settings(ab_dir, filename) - - # Currently loaded plugins objects as: - # {plugin_name: , ...} - # this list is generated at startup and never writen in settings. - # it is filled by the plugin controller - self.plugins = {} - - super().__init__(ab_dir, filename) - - if not self.healthy(): - logger.warn("Settings health check failed, resetting") - self.restore_default_settings() - - def healthy(self) -> bool: - """ - Checks the settings file to see if it is healthy. Returns True if all checks pass, otherwise returns False. - """ - healthy = True - - # check for write access to the current bw dir - healthy = healthy and os.access(self.settings.get("current_bw_dir"), os.W_OK) - - # check for write access to the custom bw dirs - access = [os.access(path, os.W_OK) for path in self.settings.get("custom_bw_dirs")] - healthy = healthy and False not in access - - return healthy - - @staticmethod - def update_old_settings(directory: str, filename: str) -> None: - """Recycling code to enable backward compatibility: This function is only required for compatibility - with the old settings file and can be removed in a future release - """ - file = os.path.join(directory, filename) - if not os.path.exists(file): - package_dir = Path(__file__).resolve().parents[1] - old_settings = os.path.join(package_dir, "ABsettings.json") - if os.path.exists(old_settings): - shutil.copyfile(old_settings, file) - if os.path.isfile(file): - with open(file, "r") as current: - current_settings = json.load(current) - if "current_bw_dir" not in current_settings: - new_settings_content = { - "current_bw_dir": current_settings["custom_bw_dir"], - "custom_bw_dirs": [current_settings["custom_bw_dir"]], - "startup_project": current_settings["startup_project"], - } - with open(file, "w") as new_file: - json.dump(new_settings_content, new_file, default=pathlib_encoder) - - @classmethod - def get_default_settings(cls) -> dict: - """Using methods from the commontasks file to set default settings""" - return { - "current_bw_dir": cls.get_default_directory(), - "custom_bw_dirs": [cls.get_default_directory()], - "startup_project": cls.get_default_project_name(), - } - - @property - def custom_bw_dir(self) -> str: - """Returns the custom brightway directory, or the default""" - return self.settings.get("custom_bw_dirs", self.get_default_directory()) - - @property - def current_bw_dir(self) -> str: - """Returns the current brightway directory""" - return self.settings.get("current_bw_dir", self.get_default_directory()) - - @current_bw_dir.setter - def current_bw_dir(self, directory: str) -> None: - self.settings["current_bw_dir"] = directory - self.write_settings() - - @custom_bw_dir.setter - def custom_bw_dir(self, directory: str) -> None: - """Sets the custom brightway directory to `directory`""" - if directory not in self.settings["custom_bw_dirs"]: - self.settings["custom_bw_dirs"].append(directory) - self.write_settings() - - def remove_custom_bw_dir(self, directory: str) -> None: - """Removes the brightway directory to 'directory'""" - try: - self.settings["custom_bw_dirs"].remove(directory) - self.write_settings() - except KeyError as e: - QMessageBox.warning( - self, - f"Error while attempting to remove a brightway environmental dir: {e}", - ) - - @property - def startup_project(self) -> str: - """Get the startup project from the settings, or the default""" - project = self.settings.get("startup_project", self.get_default_project_name()) - if project and project not in bd.projects: - project = self.get_default_project_name() - return project - - @startup_project.setter - def startup_project(self, project: str) -> None: - """Sets the startup project to `project`""" - self.settings.update({"startup_project": project}) - - @staticmethod - def get_default_directory() -> str: - """Returns the default brightway application directory""" - return DEFAULT_BW_DATA_DIR - - @staticmethod - def get_default_project_name() -> Optional[str]: - """Returns the default project name.""" - if "default" in bd.projects: - return "default" - elif len(bd.projects): - return next(iter(bd.projects)).name - else: - return None - - @property - def theme(self) -> str: - """Returns the current brightway directory""" - return self.settings.get("theme", "Light theme") - - @theme.setter - def theme(self, new_theme: str) -> None: - self.settings.update({"theme": new_theme}) - - -class ProjectSettings(BaseSettings): - """ - Handles user settings which are specific to projects. Created initially to handle read-only/writable database status - Code based on ABSettings class, if more different types of settings are needed, could inherit from a base class - - structure: singleton, loaded dependent on which project is selected. - Persisted on disc, Stored in the BW2 projects data folder for each project - a dictionary1 of dictionaries2 - Dictionary1 keys are settings names (currently just 'read-only-databases'), values are dictionary2s - Dictionary2 keys are database names, values are bools - - For now, decided to not include saving writable-activities to settings. - As activities are identified by tuples, and saving them to json requires extra code - https://stackoverflow.com/questions/15721363/preserve-python-tuples-with-json - This is currently not worth the effort but could be returned to later - - """ - - def __init__(self, filename: str): - # on selection of a project (signal?), find the settings file for that project if it exists - # it can be a custom location, based on ABsettings. So check that, and if not, use default? - # once found, load the settings or just an empty dict. - self.connect_signals() - super().__init__(bd.projects.dir, filename) - - bd.projects.dir.joinpath("activity_browser").mkdir(exist_ok=True) - - # https://github.com/LCA-ActivityBrowser/activity-browser/issues/235 - # Fix empty settings file and populate with currently active databases - if "read-only-databases" not in self.settings: - self.settings.update(self.process_brightway_databases()) - self.write_settings() - if "plugins_list" not in self.settings: - self.settings.update({"plugins_list": []}) - self.write_settings() - - def connect_signals(self): - - # Reload the project settings whenever a project switch occurs. - signals.project.changed.connect(self.reset_for_project_selection) - - # save new plugin for this project - signals.plugin_selected.connect(self.add_plugin) - - @classmethod - def get_default_settings(cls) -> dict: - """Return default empty settings dictionary.""" - settings = cls.process_brightway_databases() - settings["plugins_list"] = [] - return settings - - @staticmethod - def process_brightway_databases() -> dict: - """Process brightway database list and return new settings dictionary. - - NOTE: This ignores the existing database read-only settings. - """ - return {"read-only-databases": {name: True for name in bd.databases.list}} - - def reset_for_project_selection(self) -> None: - """On switching project, attempt to read the settings for the new - project. - """ - logger.info(f"Project settings directory: {bd.projects.dir}") - - bd.projects.dir.joinpath("activity_browser").mkdir(exist_ok=True) - - self.settings_file = os.path.join(bd.projects.dir, self.filename) - self.initialize_settings() - # create a plugins_list entry for old projects - if "plugins_list" not in self.settings: - self.settings.update({"plugins_list": []}) - self.write_settings() - - def add_db(self, db_name: str, read_only: bool = True) -> None: - """Store new databases and relevant settings here when created/imported""" - self.settings["read-only-databases"].setdefault(db_name, read_only) - self.write_settings() - - def modify_db(self, db_name: str, read_only: bool) -> None: - """Update write-rules for the given database""" - self.settings["read-only-databases"].update({db_name: read_only}) - self.write_settings() - - def remove_db(self, db_name: str) -> None: - """When a database is deleted from a project, the settings are also deleted.""" - self.settings["read-only-databases"].pop(db_name, None) - self.write_settings() - - def db_is_readonly(self, db_name: str) -> bool: - """Check if given database is read-only, defaults to yes.""" - return self.settings["read-only-databases"].get(db_name, True) - - def get_editable_databases(self): - """Return list of database names where read-only is false - - NOTE: discards the biosphere3 database based on name. - """ - iterator = self.settings.get("read-only-databases", {}).items() - return (name for name, ro in iterator if not ro and name != "biosphere3") - - def add_plugin(self, name: str, select: bool = True): - """Add a plugin to settings or remove it""" - if select: - self.settings["plugins_list"].append(name) - self.write_settings() - return - if name in self.settings["plugins_list"]: - self.settings["plugins_list"].remove(name) - self.write_settings() - - def get_plugins_list(self): - """Return a list of plugins names""" - return self.settings["plugins_list"] - - -ab_settings = ABSettings("ABsettings.json") -project_settings = ProjectSettings("AB_project_settings.json") diff --git a/activity_browser/ui/web/base.py b/activity_browser/ui/web/base.py index fdb363926..f729e77d8 100644 --- a/activity_browser/ui/web/base.py +++ b/activity_browser/ui/web/base.py @@ -5,12 +5,13 @@ from typing import Type from loguru import logger +import bw2data as bd + from qtpy import QtWebChannel, QtWebEngineWidgets, QtWidgets from qtpy.QtCore import QObject, Qt, QUrl, Signal, Slot from activity_browser import app -from activity_browser.settings import ab_settings -from activity_browser.mod import bw2data as bd +from activity_browser.bwutils import filesystem from ...ui.icons import qicons from . import webutils @@ -117,7 +118,7 @@ def savefilepath(default_file_name: str, file_filter: str = ALL_FILTER): safe_name = bd.utils.safe_filename(default, add_hash=False) filepath, _ = QtWidgets.QFileDialog.getSaveFileName( caption="Choose location to save svg", - dir=os.path.join(ab_settings.data_dir, safe_name), + dir=os.path.join(filesystem.get_project_path(), safe_name), filter=file_filter, ) return filepath diff --git a/activity_browser/ui/wizards/README.md b/activity_browser/ui/wizards/README.md deleted file mode 100644 index dff2884c7..000000000 --- a/activity_browser/ui/wizards/README.md +++ /dev/null @@ -1,295 +0,0 @@ -# wizards - -Multi-step wizard dialogs for complex workflows. - -## Overview - -This directory contains wizard dialogs that guide users through multi-step processes such as database import, project setup, and configuration tasks. Wizards provide a structured approach to complex operations. - -## What is a Wizard? - -A wizard is a multi-page dialog that: -- Guides users step-by-step through a process -- Validates input at each step -- Allows forward/backward navigation -- Shows progress through the workflow -- Collects all necessary information before completion - -## When to Use Wizards - -Use wizards for: -- **Complex setup** - Initial configuration with many options -- **Multi-step workflows** - Processes requiring sequential steps -- **Data collection** - Gathering information in logical groups -- **Import/export** - File selection, options, mapping, preview -- **Guided operations** - Help users through unfamiliar tasks - -Don't use wizards for: -- Simple forms (use a regular dialog) -- Single-step operations -- Expert users who know what they want -- When flexibility in order is needed - -## Wizard Components - -### Wizard Dialog -The main container (inherits from `QWizard`): -- Manages pages -- Handles navigation -- Provides standard buttons (Next, Back, Finish, Cancel) -- Tracks completion state - -### Wizard Pages -Individual steps (inherit from `QWizardPage`): -- Collect specific information -- Validate input -- Determine if page is complete -- Navigate to next page - -## Usage Pattern - -### Creating a Wizard - -```python -from qtpy.QtWidgets import QWizard - -class MyWizard(QWizard): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("My Wizard") - - # Add pages - self.addPage(IntroPage()) - self.addPage(ConfigPage()) - self.addPage(ConfirmPage()) - - def accept(self): - # Process collected data - self.process_results() - super().accept() -``` - -### Creating Wizard Pages - -```python -from qtpy.QtWidgets import QWizardPage, QVBoxLayout, QLineEdit - -class ConfigPage(QWizardPage): - def __init__(self, parent=None): - super().__init__(parent) - self.setTitle("Configuration") - self.setSubTitle("Enter configuration details") - - layout = QVBoxLayout(self) - self.name_edit = QLineEdit() - self.registerField("name*", self.name_edit) # * = required - layout.addWidget(self.name_edit) - - def validatePage(self): - """Called when Next is clicked.""" - if not self.name_edit.text(): - return False - return True -``` - -## Wizard Features - -### Field Registration -Share data between pages: -```python -# In page 1 -self.registerField("database_name*", self.name_edit) - -# In page 2 -db_name = self.field("database_name") -``` - -### Required Fields -Mark fields as required (asterisk): -```python -self.registerField("email*", self.email_edit) -``` - -Next button is disabled until all required fields are filled. - -### Page Validation -Control when users can proceed: -```python -def validatePage(self): - """Return False to prevent proceeding.""" - if not self.validate_input(): - QMessageBox.warning(self, "Error", "Invalid input") - return False - return True -``` - -### Conditional Navigation -Skip pages based on choices: -```python -def nextId(self): - """Return ID of next page.""" - if self.skip_option.isChecked(): - return self.SummaryPage # Skip intermediate pages - return super().nextId() -``` - -### Dynamic Content -Update pages based on previous choices: -```python -def initializePage(self): - """Called when page is shown.""" - selection = self.field("user_selection") - self.update_options(selection) -``` - -## Wizard Buttons - -### Standard Buttons -- **Next** - Proceed to next page (calls `validatePage()`) -- **Back** - Return to previous page -- **Finish** - Complete wizard (calls `accept()`) -- **Cancel** - Abort wizard (calls `reject()`) -- **Help** - Show help (optional, connect to help system) - -### Customizing Buttons -```python -self.setButtonText(QWizard.NextButton, "Continue") -self.setButtonText(QWizard.FinishButton, "Import") -``` - -### Button Visibility -```python -self.button(QWizard.BackButton).setVisible(False) -``` - -## Wizard Styles - -Choose wizard style: -```python -# Modern style (default) -wizard.setWizardStyle(QWizard.ModernStyle) - -# Classic style with sidebar -wizard.setWizardStyle(QWizard.ClassicStyle) - -# Mac style -wizard.setWizardStyle(QWizard.MacStyle) -``` - -## Page Types - -### Intro Page -Welcome and overview: -```python -class IntroPage(QWizardPage): - def __init__(self): - super().__init__() - self.setTitle("Welcome") - layout = QVBoxLayout(self) - layout.addWidget(QLabel("This wizard will guide you...")) -``` - -### Input Page -Collect user input: -```python -class InputPage(QWizardPage): - def __init__(self): - super().__init__() - self.setTitle("Enter Information") - # Add input fields -``` - -### Selection Page -Choose options: -```python -class SelectionPage(QWizardPage): - def __init__(self): - super().__init__() - self.setTitle("Select Options") - # Add radio buttons or checkboxes -``` - -### Preview Page -Review before finishing: -```python -class PreviewPage(QWizardPage): - def initializePage(self): - # Show summary of all choices - summary = self.generate_summary() - self.label.setText(summary) -``` - -## Threading in Wizards - -Long operations in `accept()`: -```python -def accept(self): - # Show progress - self.progress = QProgressDialog("Importing...", None, 0, 0, self) - self.progress.show() - - # Run in background - worker = ABThread(self.import_data) - worker.finished.connect(self.on_complete) - worker.start() - -def on_complete(self): - self.progress.close() - super().accept() -``` - -## Development Guidelines - -When creating wizards: - -1. **Plan page flow** - Map out all steps before coding -2. **Keep pages focused** - One task per page -3. **Validate early** - Check input on each page -4. **Provide context** - Clear titles and subtitles -5. **Use fields wisely** - Register fields to share data -6. **Enable navigation** - Implement validatePage() properly -7. **Show progress** - Use page numbers or progress indicator -8. **Provide help** - Add help button with useful information -9. **Test all paths** - Verify all navigation possibilities -10. **Handle cancellation** - Clean up partial work - -## Example: Import Wizard - -```python -class ImportWizard(QWizard): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Import Database") - - self.file_page = FileSelectionPage() - self.options_page = ImportOptionsPage() - self.preview_page = PreviewPage() - - self.addPage(self.file_page) - self.addPage(self.options_page) - self.addPage(self.preview_page) - - def accept(self): - filepath = self.field("filepath") - options = self.get_options() - self.perform_import(filepath, options) - super().accept() -``` - -## Integration with Actions - -Wizards are typically opened via actions: - -```python -from activity_browser.app.actions.base import ABAction - -class OpenImportWizard(ABAction): - text = "Import Database..." - - @staticmethod - def run(): - wizard = ImportWizard() - if wizard.exec_() == QWizard.Accepted: - # Import completed successfully - pass -``` diff --git a/activity_browser/ui/wizards/__init__.py b/activity_browser/ui/wizards/__init__.py deleted file mode 100644 index 0eb2c33ff..000000000 --- a/activity_browser/ui/wizards/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ..dialogs.uncertainty import UncertaintyWizard diff --git a/activity_browser/ui/wizards/settings_wizard.py b/activity_browser/ui/wizards/settings_wizard.py deleted file mode 100644 index 109a11e88..000000000 --- a/activity_browser/ui/wizards/settings_wizard.py +++ /dev/null @@ -1,316 +0,0 @@ -# -*- coding: utf-8 -*- -import os -from loguru import logger -from pathlib import Path - -from peewee import SqliteDatabase, OperationalError -from qtpy import QtCore, QtWidgets - -from bw2data import projects - -from activity_browser.settings import ab_settings - - - - -class SettingsWizard(QtWidgets.QWizard): - def __init__(self, parent=None): - super().__init__(parent) - self.last_project = projects.current - self.last_bwdir = projects._base_data_dir - - self.setWindowTitle("Activity Browser Settings") - self.settings_page = SettingsPage(self) - self.addPage(self.settings_page) - self.show() - self.button(QtWidgets.QWizard.BackButton).hide() - self.button(QtWidgets.QWizard.FinishButton).clicked.connect(self.save_settings) - self.button(QtWidgets.QWizard.CancelButton).clicked.connect(self.cancel) - - def save_settings(self): - # directory - current_bw_dir = ab_settings.current_bw_dir - field = self.field("current_bw_dir") - if field and field != current_bw_dir: - ab_settings.custom_bw_dir = field - ab_settings.current_bw_dir = field - logger.info(f"Saved startup brightway directory as: {field}") - - # project - field_project = self.field("startup_project") - current_startup_project = ab_settings.startup_project - if field_project and field_project != current_startup_project: - new_startup_project = field_project - ab_settings.startup_project = new_startup_project - logger.info(f"Saved startup project as: {new_startup_project}") - - ab_settings.write_settings() - projects.change_base_directories(Path(field), update=False) - - def cancel(self): - logger.info("Going back to before settings were changed.") - if projects._base_data_dir != self.last_bwdir: - projects.change_base_directories(Path(self.last_bwdir), update=False) - projects.set_current( - self.last_project, update=False, - ) # project changes only if directory is changed - - -class SettingsPage(QtWidgets.QWizardPage): - # TODO Look to add a hover event for switching spaces - def __init__(self, parent=None): - super().__init__(parent) - self.wizard = parent - self.complete = False - - # bw dir - self.bwdir_variables = set() - self.bwdir = QtWidgets.QComboBox() - - self.bwdir_browse_button = QtWidgets.QPushButton("Browse") - self.bwdir_remove_button = QtWidgets.QPushButton("Remove") - self.update_combobox(self.bwdir, ab_settings.custom_bw_dir) - self.restore_defaults_button = QtWidgets.QPushButton("Restore defaults") - self.bwdir_name = QtWidgets.QLineEdit(self.bwdir.currentText()) - self.registerField("current_bw_dir", self.bwdir_name) - - # startup project - self.startup_project_combobox = QtWidgets.QComboBox() - self.update_project_combo() - - self.registerField( - "startup_project", self.startup_project_combobox, "currentText" - ) - - # light/dark theme - self.theme_combo = QtWidgets.QComboBox() - self.theme_combo.addItems([ - "Light theme", - "Dark theme compatibility" - ]) - self.theme_combo.setCurrentText(ab_settings.theme) - self.registerField( - "theme_cbox", self.theme_combo, "currentText" - ) - - # Startup options - self.startup_groupbox = QtWidgets.QGroupBox("Startup Options") - self.startup_layout = QtWidgets.QGridLayout() - self.startup_layout.addWidget(QtWidgets.QLabel("Brightway Dir: "), 0, 0) - self.startup_layout.addWidget(self.bwdir, 0, 1) - self.startup_layout.addWidget(self.bwdir_browse_button, 0, 2) - self.startup_layout.addWidget(self.bwdir_remove_button, 0, 3) - self.startup_layout.addWidget(QtWidgets.QLabel("Startup Project: "), 1, 0) - self.startup_layout.addWidget(self.startup_project_combobox, 1, 1) - self.startup_layout.addWidget(QtWidgets.QLabel("Theme: "), 2, 0) - self.startup_layout.addWidget(self.theme_combo, 2, 1) - self.startup_layout.addWidget(QtWidgets.QLabel("(Requires restart)"), 2, 2) - - self.startup_groupbox.setLayout(self.startup_layout) - - self.layout = QtWidgets.QVBoxLayout() - self.layout.addWidget(self.startup_groupbox) - self.layout.addStretch() - self.layout.addWidget(self.restore_defaults_button) - self.setLayout(self.layout) - self.setFinalPage(True) - self.setButtonText(QtWidgets.QWizard.FinishButton, "Save") - - # signals - self.startup_project_combobox.currentIndexChanged.connect(self.changed) - self.bwdir_browse_button.clicked.connect(self.bwdir_browse) - self.bwdir_remove_button.clicked.connect(self.bwdir_remove) - self.bwdir.currentTextChanged.connect(self.bwdir_change) - self.theme_combo.currentTextChanged.connect(self.theme_change) - self.restore_defaults_button.clicked.connect(self.restore_defaults) - - def bw_projects(self, path: str): - """Finds the bw_projects from the brightway2 environment provided by path""" - # open the project database - database_file = os.path.join(path, "projects.db") - if not os.path.exists(database_file): - return [] - db = SqliteDatabase(database_file) - - # find all project names using sql query and return - try: - cursor = db.execute_sql('SELECT "name" FROM "projectdataset"') - except OperationalError as e: - if "no such table" in str(e): - return [] - raise - return [i[0] for i in cursor.fetchall()] - - def restore_defaults(self): - self.change_bw_dir(ab_settings.get_default_directory()) - self.startup_project_combobox.setCurrentText( - ab_settings.get_default_project_name() - ) - - def bwdir_remove(self): - """ - Removes the project from the AB settings, has additional possiblity of removing data - contained on 'disk'. Provides a warning before execution. - """ - hard_deletion = QtWidgets.QMessageBox.question( - self, - "Delete Brightway2 directory?", - "This action will remove the local information only, click" - "'Yes' to remove\nthe projects. Data on the \"disk\" will remain" - " untouched and needs to be removed manually", - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.Cancel, - ) - if hard_deletion == QtWidgets.QMessageBox.Cancel: - return - - removed_dir = self.bwdir.currentText() - removed_index = self.bwdir.currentIndex() - self.bwdir.blockSignals(True) - self.bwdir.setCurrentIndex(-1) - self.bwdir.removeItem(removed_index) - self.bwdir.blockSignals(False) - self.bwdir_variables.remove(removed_dir) - ab_settings.remove_custom_bw_dir(removed_dir) - - def bwdir_change(self, path: str): - """ - Executes on emission of a signal from changes to the QComboBox holding bw2 environments - Scope: Limited to - SettingsPage class - can create new environments and bw2data.projects (exceptions are permitted), will update - contents of the Project QComboBox - settings::ABSettings - uses but doesn't set bw2 variables, sets variables in the settings file - """ - self.change_bw_dir(path) - - def theme_change(self, theme: str): - """Change the theme.""" - if ab_settings.theme != theme: - ab_settings.theme = theme - self.changed() - - def bwdir_browse(self): - """ - Executes on emission of a signal from the browse button - Scope: Limited to - SettingsPage class - provides a file path as a string to the QComboBox holding - bw2data environments - """ - path = QtWidgets.QFileDialog.getExistingDirectory( - self, "Select a brightway2 database folder" - ) - if path: - self.change_bw_dir(os.path.normpath(path)) - - def change_bw_dir(self, path): - """Set startup brightway directory. - Switch to this directory if user wishes (this will update the "projects" combobox correctly). - """ - - # if no projects exist in this directory: ask user if he wants to set up a new brightway data directory here - if not os.path.isfile(os.path.join(path, "projects.db")): - create_new_directory = QtWidgets.QMessageBox.question( - self, - "New brightway data directory?", - 'This directory does not contain any projects. \n Would you like to setup a new brightway data directory here? \n This will close the current project and create a "default" project in the new directory.', - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.Cancel, - ) - if create_new_directory == QtWidgets.QMessageBox.Cancel: - return - else: - self.bwdir_name.setText(path) - self.registerField("current_bw_dir", self.bwdir_name) - self.combobox_add_dir(self.bwdir, path) - # ab_settings.current_bw_dir = path - ab_settings.startup_project = "" - self.bwdir.blockSignals(True) - self.bwdir.setCurrentText(self.bwdir_name.text()) - self.bwdir.blockSignals(False) - self.update_project_combo(path=self.bwdir_name.text()) - self.changed() - else: # a project already exists in this directory - # ask user if to switch directory (which will update the project combobox correctly) - reply = QtWidgets.QMessageBox.question( - self, - "Continue?", - 'Would you like to switch to this directory now? \nThis will close your currently opened project. \nClick "Yes" to be able to choose the startup project.', - QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No, - ) - if path not in self.bwdir_variables: - self.combobox_add_dir(self.bwdir, path) - if reply == QtWidgets.QMessageBox.Yes: - self.bwdir_name.setText(path) - self.registerField("current_bw_dir", self.bwdir_name) - # ab_settings.current_bw_dir = path - self.update_project_combo(path=self.bwdir_name.text()) - else: - prev_env_index = self.bwdir.findText( - self.bwdir_name.text(), QtCore.Qt.MatchFixedString - ) - self.bwdir.blockSignals(True) - self.bwdir.setCurrentIndex(prev_env_index) - self.bwdir.blockSignals(False) - self.changed() - - def update_project_combo(self, initialization: bool = True, path: str = None): - """ - Updates the project combobox when loading a new brightway environment - """ - self.startup_project_combobox.clear() - if path: - self.project_names = self.bw_projects(path) - else: - self.project_names = self.bw_projects(ab_settings.current_bw_dir) - if self.project_names: - self.startup_project_combobox.addItems(self.project_names) - else: - logger.warning("No projects found in this directory.") - if ab_settings.startup_project in self.project_names: - self.startup_project_combobox.setCurrentText(ab_settings.startup_project) - else: - ab_settings.startup_project = "" - self.startup_project_combobox.setCurrentIndex(-1) - if not initialization: - self.changed() - - def combobox_add_dir(self, box: QtWidgets.QComboBox, path: str) -> None: - """Adds a single directory to the QComboBox.""" - box.blockSignals(True) - box.addItems([path]) - box.blockSignals(False) - if path not in self.bwdir_variables: - self.bwdir_variables.add(path) - ab_settings.custom_bw_dir = path - - def update_combobox(self, box: QtWidgets.QComboBox, labels: list) -> None: - """Update the combobox menu.""" - correct_settings = False - current_dir = ab_settings.current_bw_dir - for i, dir in enumerate(ab_settings.custom_bw_dir): - self.bwdir_variables.add(dir) - if dir == current_dir: - box.blockSignals(True) - box.clear() - box.insertItems(0, labels) - box.blockSignals(False) - box.setCurrentIndex(i) - correct_settings = True - if correct_settings: - return - QtWidgets.QMessageBox.warning( - self, - "Discrepancy in the ABsettings.json file", - "The value provided for the current brightway directory does not exist\n" - "in the available list of directories. Please check the settings file.", - QtWidgets.QMessageBox.Ok, - ) - - def changed(self): - self.wizard.button(QtWidgets.QWizard.BackButton).hide() - self.complete = True - self.completeChanged.emit() - - def isComplete(self): - return self.complete \ No newline at end of file From 9d04b4a3ad6a6aec366df27d502fcf94fc24a98f Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 12:10:29 +0100 Subject: [PATCH 225/267] Remove deprecated docs --- activity_browser/docs/wiki/Activities.md | 5 - activity_browser/docs/wiki/Databases.md | 99 ---- activity_browser/docs/wiki/Flow-Scenarios.md | 35 -- activity_browser/docs/wiki/Getting-Started.md | 167 ------ activity_browser/docs/wiki/Graph-Explorer.md | 5 - activity_browser/docs/wiki/Home.md | 53 -- .../docs/wiki/Impact-Categories.md | 5 - .../docs/wiki/Installation-Guide.md | 103 ---- .../docs/wiki/LCA-Calculation-Setups.md | 5 - activity_browser/docs/wiki/LCA-Results.md | 222 -------- activity_browser/docs/wiki/Need-Help.md | 8 - activity_browser/docs/wiki/Parameters.md | 86 ---- activity_browser/docs/wiki/Plugins.md | 66 --- activity_browser/docs/wiki/Projects.md | 85 ---- activity_browser/docs/wiki/Settings.md | 5 - activity_browser/docs/wiki/Tutorials.md | 343 ------------- activity_browser/docs/wiki/Uncertainty.md | 54 -- activity_browser/docs/wiki/_Footer.md | 13 - activity_browser/docs/wiki/_Sidebar.md | 65 --- .../first_lca_tutorial_act_details_1.png | Bin 22913 -> 0 bytes .../first_lca_tutorial_act_details_2.png | Bin 16921 -> 0 bytes .../first_lca_tutorial_act_details_3.png | Bin 31106 -> 0 bytes .../first_lca_tutorial_create_act_context.png | Bin 20193 -> 0 bytes .../first_lca_tutorial_create_db.png | Bin 27936 -> 0 bytes .../first_lca_tutorial_db_graph_explorer.png | Bin 22782 -> 0 bytes .../first_lca_tutorial_graph_explorer.png | Bin 22623 -> 0 bytes .../first_lca_tutorial_lca_results.png | Bin 52238 -> 0 bytes .../first_lca_tutorial_lca_setup.png | Bin 30619 -> 0 bytes .../docs/wiki/assets/activitybrowser.png | Bin 14118 -> 0 bytes .../docs/wiki/assets/brightway_org-scheme.png | Bin 249518 -> 0 bytes .../assets/ca_positive_negative_example.png | Bin 96388 -> 0 bytes .../wiki/assets/contribution_manipulation.png | Bin 43335 -> 0 bytes .../global_sensitivity_analysis_results.jpg | Bin 385693 -> 0 bytes .../docs/wiki/assets/monte_carlo_results.jpg | Bin 100234 -> 0 bytes ...view_global_sensitivity_analysis_setup.jpg | Bin 52828 -> 0 bytes .../assets/overview_monte_carlo_setup.jpg | Bin 40242 -> 0 bytes .../assets/project_setup_dialog_bio_vsn.png | Bin 10878 -> 0 bytes .../project_setup_dialog_choose_type.png | Bin 29031 -> 0 bytes .../assets/project_setup_dialog_ei_login.png | Bin 10987 -> 0 bytes .../project_setup_dialog_ei_vsn_and_model.png | Bin 9426 -> 0 bytes .../assets/project_tab_until_databases.png | Bin 11923 -> 0 bytes .../docs/wiki/assets/sankey_example.svg | 481 ------------------ .../wiki/assets/sdf_addition_combination.png | Bin 337145 -> 0 bytes .../wiki/assets/sdf_product_combination.png | Bin 345368 -> 0 bytes .../wiki/assets/steel_production_example.svg | 373 -------------- 45 files changed, 2278 deletions(-) delete mode 100644 activity_browser/docs/wiki/Activities.md delete mode 100644 activity_browser/docs/wiki/Databases.md delete mode 100644 activity_browser/docs/wiki/Flow-Scenarios.md delete mode 100644 activity_browser/docs/wiki/Getting-Started.md delete mode 100644 activity_browser/docs/wiki/Graph-Explorer.md delete mode 100644 activity_browser/docs/wiki/Home.md delete mode 100644 activity_browser/docs/wiki/Impact-Categories.md delete mode 100644 activity_browser/docs/wiki/Installation-Guide.md delete mode 100644 activity_browser/docs/wiki/LCA-Calculation-Setups.md delete mode 100644 activity_browser/docs/wiki/LCA-Results.md delete mode 100644 activity_browser/docs/wiki/Need-Help.md delete mode 100644 activity_browser/docs/wiki/Parameters.md delete mode 100644 activity_browser/docs/wiki/Plugins.md delete mode 100644 activity_browser/docs/wiki/Projects.md delete mode 100644 activity_browser/docs/wiki/Settings.md delete mode 100644 activity_browser/docs/wiki/Tutorials.md delete mode 100644 activity_browser/docs/wiki/Uncertainty.md delete mode 100644 activity_browser/docs/wiki/_Footer.md delete mode 100644 activity_browser/docs/wiki/_Sidebar.md delete mode 100755 activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_1.png delete mode 100755 activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_2.png delete mode 100755 activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_3.png delete mode 100755 activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_create_act_context.png delete mode 100755 activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_create_db.png delete mode 100755 activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_db_graph_explorer.png delete mode 100755 activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_graph_explorer.png delete mode 100755 activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_lca_results.png delete mode 100755 activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_lca_setup.png delete mode 100644 activity_browser/docs/wiki/assets/activitybrowser.png delete mode 100644 activity_browser/docs/wiki/assets/brightway_org-scheme.png delete mode 100644 activity_browser/docs/wiki/assets/ca_positive_negative_example.png delete mode 100644 activity_browser/docs/wiki/assets/contribution_manipulation.png delete mode 100644 activity_browser/docs/wiki/assets/global_sensitivity_analysis_results.jpg delete mode 100644 activity_browser/docs/wiki/assets/monte_carlo_results.jpg delete mode 100644 activity_browser/docs/wiki/assets/overview_global_sensitivity_analysis_setup.jpg delete mode 100644 activity_browser/docs/wiki/assets/overview_monte_carlo_setup.jpg delete mode 100755 activity_browser/docs/wiki/assets/project_setup_dialog_bio_vsn.png delete mode 100755 activity_browser/docs/wiki/assets/project_setup_dialog_choose_type.png delete mode 100755 activity_browser/docs/wiki/assets/project_setup_dialog_ei_login.png delete mode 100755 activity_browser/docs/wiki/assets/project_setup_dialog_ei_vsn_and_model.png delete mode 100755 activity_browser/docs/wiki/assets/project_tab_until_databases.png delete mode 100644 activity_browser/docs/wiki/assets/sankey_example.svg delete mode 100644 activity_browser/docs/wiki/assets/sdf_addition_combination.png delete mode 100644 activity_browser/docs/wiki/assets/sdf_product_combination.png delete mode 100644 activity_browser/docs/wiki/assets/steel_production_example.svg diff --git a/activity_browser/docs/wiki/Activities.md b/activity_browser/docs/wiki/Activities.md deleted file mode 100644 index 6308e1128..000000000 --- a/activity_browser/docs/wiki/Activities.md +++ /dev/null @@ -1,5 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). diff --git a/activity_browser/docs/wiki/Databases.md b/activity_browser/docs/wiki/Databases.md deleted file mode 100644 index 1b76cda72..000000000 --- a/activity_browser/docs/wiki/Databases.md +++ /dev/null @@ -1,99 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). - -DatabasesPane are the main way in which Brightway manages and stores [Activities](Activities). -Use databases to organize your data in a meaningful way, for example by separating foreground and background systems. - -[Read more about data organization in Brightway...](Getting-Started#organization-of-data-in-brightway-and-activity-browser) - -Brightway databases consist of two parts: - -1. **Backend:** this is where the actual activity data lives. - Most users will be using the SQLite backend, which stores data in the _databases.db_ found in the project folder. -2. **Metadata:** this is where database specific metadata is stored, such as dependent databases, number of activities, - and time of last edit. - -DatabasesPane that are installed in a project may be found in the `DatabasesPane` section, part of the `Project` panel. -This section shows a table that displays a selection of the metadata for all the databases in the project. - -> [!NOTE] -> This panel is not yet visible when no databases have been installed into the project yet. -> Instead, a button to set up your project will be shown. -> -> [Read more about setting up a project...](Getting-Started#setting-up-a-project) - -## Basic functions - -### Opening a database -You can open a database by double-clicking its entry within the `DatabasesPane` table. -This will open a tab at the bottom of the `Project` panel that contains a table showing all [activities](Activities) -that the database contains. - -### Creating a new database -You can create a new database by clicking the `New database...` button in the `DatabasesPane` table. -This will prompt you to enter a unique name for the database, after which the newly created database will open and you -can start adding activities as desired. - -### Deleting a database -You can delete a database by right-clicking on its entry withing the `DatabasesPane` table and selecting `Delete database`, -this will prompt you for a confirmation. - -> [!WARNING] -> Deleting a database can not be undone and any exchanges between activities in the database and any other database will -> be deleted, all activities in the database that were used in a calculation setup will also be removed from the setup. -> -> Make sure you anticipate the consequences of deleting a database before doing so! - -### Duplicating a database -You can duplicate a database by right-clicking on its entry withing the `DatabasesPane` table. -This will prompt to enter a unique name for the new database, after which the newly duplicated database will open. - -### Relinking a database -DatabasesPane are often connected to other databases by exchanges. -Sometimes, you may want to replace the connections from a database to another, as an example: - -You have 2 databases, database _A_ and _B_, _B_ uses activities that are in _A_. -You duplicated a database _A_ to make and test some changes to _A_copy_, and now want to change the links in _B_ to _A_copy_. - -To relink a database, you can right-click on its entry in the `DatabasesPane` table and choose `Relink the database`. -In the pop-up, you can choose a new link for every database your database depends on. - -Relinking will only work if exact matches are found for the `name`, `reference product` adn `unit` for the activities. -Any activities not relinked will remain linked to the old database. - -> [!NOTE] -> Relinking can be a slow process, as it needs to check every exchange in every activity in the database. - -[//]: # (# Importing) - -[//]: # (Importing databases is an important aspect of project management. However, there are a myriad of different file formats ) - -[//]: # (and standards around for LCA data. Activity Browser covers importing for the following formats:) - -[//]: # (- Ecospold) - -[//]: # (- .bw2data packages) - -[//]: # (- Excels in the Brightway2 format) - -[//]: # () -[//]: # () -[//]: # (## Database import wizard) - -[//]: # () -[//]: # () -[//]: # (# Exporting) - -[//]: # () -[//]: # (## Database export wizard) - -[//]: # () -[//]: # () -[//]: # (# Specific tooling) - -[//]: # () -[//]: # (# Database relinking) - diff --git a/activity_browser/docs/wiki/Flow-Scenarios.md b/activity_browser/docs/wiki/Flow-Scenarios.md deleted file mode 100644 index 888267044..000000000 --- a/activity_browser/docs/wiki/Flow-Scenarios.md +++ /dev/null @@ -1,35 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). - - -## Combining Scenario Files -You can work with multiple scenario files for which there are with two options: - -### Product combinations -The option `Combine scenarios` will calculate every combination between scenarios, it adds more scenarios. -This yields all possible scenario combinations, e.g. file 1: A, B and file 2: X, Y yields A-X, A-Y, -B-X and B-Y, as shown in the figure below. - -![SDF product combination](./assets/sdf_product_combination.png) - -### Extend combinations -The option `Extend scenarios` will combine scenarios with the same name into a larger single scenario, -it makes the existing scenarios larger. -Scenarios from file 2 extend scenarios of file 1, e.g. file 1: A, B and file 2: A, B yields A-B, -as shown in the figure below. - -> [!IMPORTANT] -> This is only possible if scenario names are **identical** in all files, e.g. everywhere A, B). - -![SDF extend combination](./assets/sdf_addition_combination.png) - -## Video overview of modelling and calculating scenarios - -[![Projects and DatabasesPane](https://img.youtube.com/vi/3LPcpV1G_jg/hqdefault.jpg)](https://www.youtube.com/watch?v=3LPcpV1G_jg) - - -Note: some content of the video may be outdated. Written content should be more up-to-date. - diff --git a/activity_browser/docs/wiki/Getting-Started.md b/activity_browser/docs/wiki/Getting-Started.md deleted file mode 100644 index e2eaca941..000000000 --- a/activity_browser/docs/wiki/Getting-Started.md +++ /dev/null @@ -1,167 +0,0 @@ -[Learn how to install Activity Browser...](Installation-Guide) - -## Starting Activity Browser -First activate the environment where the activity browser is installed: - -```bash -conda activate ab -``` - -Then simply run `activity-browser` and the application will open. - -## Understanding Activity Browser terms -Activity Browser uses [Brightway](https://docs.brightway.dev/en/latest/) for its data management and calculations. -Brightway has its own 'accent' of LCA terms, -you can compare LCA terms from Brightway, [ISO 14044 (2006)](https://www.iso.org/standard/38498.html) and others in the -[Brightway Glossary](https://docs.brightway.dev/en/latest/content/overview/glossary.html). - -## Organization of data in Brightway and Activity Browser -Data in Brightway is organized into projects -- Projects contain databases, impact categories, calculation setups and more - - DatabasesPane contain activities (biosphere and technosphere) - - Activities are the building blocks of your LCA model -- Impact categories are used to score your LCA models against -- Calculation setups are the combinations of reference flows and impact categories that you can calculate -- Projects also contain other data, such as parameters and plugin settings. - -![brightway organizational structure](./assets/brightway_org-scheme.png) - -_Image copied from the -[Brightway documentation](https://docs.brightway.dev/en/latest/content/theory/structure.html#brightway-objects)._ - - -Read more about how data is organized in the -[Brightway documentation](https://docs.brightway.dev/en/latest/content/theory/structure.html#brightway-objects). - -## User interface -Activity Browser is organized in two panels, which themselves have tabs and a menu bar. -The left panel has a `Project` tab and an `Impact Categories` tab. -The right panel has the `Welcome` screen, `LCA setup` tab, `Parameters` tab and -if used- an `LCA Results` tab. - -The [`Project`](Projects) tab shows your current project, the databases in that project and the contents of a database if it is open. -The [`Impact Categories`](Impact-Categories) tab shows all impact categories that are installed in the current project. -The [`LCA Setup`](LCA-Calculation-Setups) tab allows you to define reference flows, impact categories and scenarios for calculations. -The [`Parameters`](Parameters) tab allows you to manage your parameters. -The [`LCA Results`](LCA-Results) tab shows the results of the calculations you do. -Finally, the menu bar at the top allows you to manage Activity Browser, Plugins and Project settings. - -## Setting up a project - -### Video overview of project setup - -[![Projects and DatabasesPane](https://img.youtube.com/vi/qWzaQjAf8ZU/hqdefault.jpg)](https://www.youtube.com/watch?v=qWzaQjAf8ZU) - - -Note: some content of the video may be outdated. Written content should be more up-to-date. - - -### Installing a biosphere and impact categories -In the `Project` tab there is initially a button called `Set up your project with default data`. -Click this button to add the default data. -This adds a `biosphere` database which contains a number of standardized biosphere flows -and compatible impact categories. - -![project setup - choose type](./assets/project_setup_dialog_choose_type.png) - -#### Setting up with Biosphere3 data -You can choose a biosphere version, which will install a biosphere database and compatible impact categories. - -> [!IMPORTANT] -> In case you want to install ecoinvent later, choosing a biosphere version will make your project compatible with -> **only** the version of biosphere you install. -> e.g. installing biosphere `3.6` will make your project only compatible with ecoinvent `3.6` databases. -> -> Setting the biosphere version is **permanent** for a project, you cannot change this version later. -> -> If you do not plan on using ecoinvent in this project, don't worry about this and choose the highest version. - -![project setup - choose biosphere version](./assets/project_setup_dialog_bio_vsn.png) - -#### Setting up with ecoinvent data -If you have a valid ecoinvent license and login information, you can immediately set up ecoinvent in your project with all -relevant and compatible data. -You can then choose the database version and system model. - -![project setup - ecoinvent login](./assets/project_setup_dialog_ei_login.png) -![project setup - ecoinvent version and system model](./assets/project_setup_dialog_ei_vsn_and_model.png) - -[Read more about projects...](Projects) - -## LCI databases -After adding the default data, you can create or import a database with the `New` and `Import Database` buttons. - -![project tab until databases](./assets/project_tab_until_databases.png) - -### New databases -With `New` you can create a completely empty database with any given name and -enter your own activity data. - -[Read more about activities...](Activities) - -### Importing databases -Clicking 'Import' will open a new dialog that will allow you to select how you want to import data into brightway -(and by extension, the Activity Browser). -There are two main options: 'remote data' and 'local data': - -
Remote database import - -We currently support 2 remote databases, Ecoinvent and Forwast: - -#### Importing Ecoinvent -[**Ecoinvent**](https://ecoinvent.org/) is a paid database you can install directly in Activity Browser if you have a -valid ecoinvent license and login information. - -#### Importing Forwast -[**Forwast**](http://forwast.brgm.fr/) is a free database you can install directly in Activity Browser. -___ -
- -
Local database import - -We support various local import methods -- Local 7z-archive of ecospold2 files -- Local directory of ecospold2 files -- Local Excel file -- Local Brightway database file -___ -
- -[Read more about databases...](Databases) - -### Video overview of working with Activities in DatabasesPane - -[![Projects and DatabasesPane](https://img.youtube.com/vi/2rmydYdscJY/hqdefault.jpg)](https://www.youtube.com/watch?v=2rmydYdscJY) - - -Note: some content of the video may be outdated. Written content should be more up-to-date. - - -[Read more about activities...](Activities) - -## Running an LCA calculation -To run an LCA, you must first create a calculation setup, add at least one reference flow and one impact category -to be able to calculate results. - -### Video overview of calculating LCA results - -[![LCA results](https://img.youtube.com/vi/J94UehVQM-Q/hqdefault.jpg)](https://www.youtube.com/watch?v=J94UehVQM-Q) - - -Note: some content of the video may be outdated. Written content should be more up-to-date. - - -[Read more about LCA calculation setups...](LCA-Calculation-Setups) - -[Read more about LCA results...](LCA-Results) - -[Follow a tutorial to do your first LCA...](Tutorials#your-first-lca) - -## Additional Resources -- [Youtube tutorials](https://www.youtube.com/channel/UCsyySKrzEMsRFsWW1Oz-6aA/) -- [Introduction video by ETH Zurich](https://www.youtube.com/watch?v=j3uLptvsxeA) -- [AB Discussions page](https://github.com/LCA-ActivityBrowser/activity-browser/discussions) -- [AB scientific article](https://doi.org/10.1016/j.simpa.2019.100012) -- The AB has two mailing lists, for [updates](https://brightway.groups.io/g/AB-updates) and [user exchange](https://brightway.groups.io/g/AB-discussion) -- [Brightway2](https://brightway.dev/) -- [Global Sensitiviy Analysis paper](https://onlinelibrary.wiley.com/doi/10.1111/jiec.13194) describing GSA as implemented in the AB; see also our [wiki](https://github.com/LCA-ActivityBrowser/activity-browser/wiki/Global-Sensitivity-Analysis) -- [Modular LCA paper](https://link.springer.com/article/10.1007/s11367-015-1015-3); [documentation modular LCA](http://activity-browser.readthedocs.io/en/latest/index.html) diff --git a/activity_browser/docs/wiki/Graph-Explorer.md b/activity_browser/docs/wiki/Graph-Explorer.md deleted file mode 100644 index 6308e1128..000000000 --- a/activity_browser/docs/wiki/Graph-Explorer.md +++ /dev/null @@ -1,5 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). diff --git a/activity_browser/docs/wiki/Home.md b/activity_browser/docs/wiki/Home.md deleted file mode 100644 index 766b95580..000000000 --- a/activity_browser/docs/wiki/Home.md +++ /dev/null @@ -1,53 +0,0 @@ -Welcome to the Activity Browser wiki! - -> [!IMPORTANT] -> Creating this wiki is an ongoing project. -> While we aim to have it as complete as possible, sections may be missing or incomplete. -> If you want to contribute to the wiki, please check out our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). - -The wiki aims to help users get started and to document all features in Activity Browser. - -## Getting started -**Check out these resources to get you started with Activity Browser** -- [Installation guide](Installation-Guide) -- [Updating Activity Browser](Installation-Guide#updating-activity-browser) -- [Getting Started](Getting-Started) - - [User interface](Getting-Started#user-interface) - - [Understanding Activity Browser terms](Getting-Started#understanding-activity-browser-terms) - - [Setting up a project](Getting-Started#setting-up-a-project) - - [LCI databases](Getting-Started#lci-databases) - - [Running an LCA calculation](Getting-Started#running-an-lca-calculation) -- [Additional Resources](Getting-Started#additional-resources) -- [Need help?](Need-Help) - -___ -## Tutorials -Have a look at our [tutorials page](Tutorials) to follow along with examples. - -- [Follow a tutorial to do your first LCA](Tutorials#your-first-lca) - -___ -## Video overview of Activity Browser - -[![What is the Activity Browser video](https://img.youtube.com/vi/oeL_FOsNYfU/hqdefault.jpg)](https://www.youtube.com/watch?v=oeL_FOsNYfU) - - -Note: some content of the video may be outdated. Written content should be more up-to-date. - - -___ -## Documentation - -- [📚 Projects](Projects) -- [📒 DatabasesPane](Databases) -- [🧾 Activities](Activities) -- [🌍 Impact Categories](Impact-Categories) -- [🧮 LCA Calculation Setups](LCA-Calculation-Setups) -- [📊 LCA Results](LCA-Results) -- [🎰 Uncertainty](Uncertainty) -- [📈 Flow Scenarios](Flow-Scenarios) -- [📈 Parameter Scenarios](Parameters) -- [🔁 Graph Explorer](Graph-Explorer) -- [🧩 Plugins](Plugins) -- [⚙️ Settings](Settings) diff --git a/activity_browser/docs/wiki/Impact-Categories.md b/activity_browser/docs/wiki/Impact-Categories.md deleted file mode 100644 index 6308e1128..000000000 --- a/activity_browser/docs/wiki/Impact-Categories.md +++ /dev/null @@ -1,5 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). diff --git a/activity_browser/docs/wiki/Installation-Guide.md b/activity_browser/docs/wiki/Installation-Guide.md deleted file mode 100644 index f5a82ed57..000000000 --- a/activity_browser/docs/wiki/Installation-Guide.md +++ /dev/null @@ -1,103 +0,0 @@ -## Introduction -Thank you for showing interesting in the Beta for Activity Browser 3.0. This release has been a year in the making and we're happy to -show of the hard work that's been put in and get it to a level where we're happy to show it to the wider public. Most notably AB3 will -support Brightway25 and multifunctionality. But there have also been a lot of changes to the look and experience of the Activity Browser. - -Making so many changes inevitably also comes with creating a lot of bugs. We have tried to catch as may of them as we could, but in the -end noone is better at finding bugs than you, our users, are. We invite you to install the Activity Browser 3.0 Beta and break it in as -many ways as you possibly can. As long as you report the bugs you find back to us, so we can fix them. - -Thank you for your help, and enjoy the new Activity Browser! - -> [!IMPORTANT] -> This is a **beta installation**. As always use of the Activity Browser is **at your own risk**, but take extra care with this installation. Back-up critical projects before opening them. - -## Distributions on PyPI and Anaconda -The Activity Browser 3 Beta is available both on [PyPI]() and [Anaconda](). Because not all necessary libraries are available on Anaconda -right now you need to do an extra `pip install` inside your Conda environment. - -#### Quick-Install PyPI -``` -pip install activity-browser -``` - -#### Quick-Install Anaconda -``` -conda create -n ab_beta -c conda-forge lca::activity-browser -conda activate ab_beta -pip install PySide6 -``` - -For more elaborate installing instructions check out the page below for both [installing from PyPI](#installing-from-pypi) and [installing from Anaconda](#installing-from-anaconda). - -## Installing from PyPI -Installing from the Python Package Index (PyPI) can be done using the standard `pip` command. We strongly recommended installing the -Activity Browser into a separate [virtual environment](https://realpython.com/python-virtual-environments-a-primer/) - -First make sure you have Python installed on your PC by entering the following command into your terminal or command prompt. - -``` -python --version -``` -If you get an error please install Python [using their install instructions](https://www.python.org/downloads/). - -### Creating a virtual environment -Firstly, create a directory for your virtual environments, such as C:/Users/me/virtualenvs/. Then create a virtual environment in that -location using the following command: -``` -python -m venv C:/Users/me/virtualenvs/ab-beta -``` -Afterwards, you need to activate the virtual environment, which differs between operating systems and shells. Using Window Command Prompt -activate the environment using this command: -``` -C:\Users\me\virtualenvs\ab-beta\Scripts\activate.bat -``` -For a full overview of activation commands, [check out the documentation here](https://docs.python.org/3/library/venv.html#how-venvs-work) - -### Activity Browser Beta installation -After creating the virtual environment, installing the Beta should be as simple as using the following command: -``` -pip install activity-browser -``` - -### Launching the Activity Browser -The Activity Browser can then be launched like so: -``` -activity-browser -``` - -## Installing from Anaconda -First make sure you have Conda installed - -``` -conda --version -``` - -If you get an error, please download and install miniconda from anaconda.com https://www.anaconda.com/download/success - -### Activity Browser Beta installation -Next we're going to create a new environment for the Activity Browser Beta release. - -``` -conda create -n ab_beta -c conda-forge lca::activity-browser -``` - -This will go through a few steps, some of which like `solving environment` may take a while. After installation has finished you can -activate the environment like so: - -``` -conda activate ab_beta -``` - -### PySide6 installation -We will need to install `PySide6` from a different source, as the fully functional version is not available on anaconda. - -``` -pip install PySide6 -``` - -### Launching the Activity Browser -Launch the Activity Browser like you would normally -``` -activity-browser -``` \ No newline at end of file diff --git a/activity_browser/docs/wiki/LCA-Calculation-Setups.md b/activity_browser/docs/wiki/LCA-Calculation-Setups.md deleted file mode 100644 index 6308e1128..000000000 --- a/activity_browser/docs/wiki/LCA-Calculation-Setups.md +++ /dev/null @@ -1,5 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). diff --git a/activity_browser/docs/wiki/LCA-Results.md b/activity_browser/docs/wiki/LCA-Results.md deleted file mode 100644 index b5717114d..000000000 --- a/activity_browser/docs/wiki/LCA-Results.md +++ /dev/null @@ -1,222 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). - -## Overview - -### Inventory - -### LCA overview results - -### Score matrix - -## Contribution Analysis -### Differences between approaches -Activity Browser has three contribution analysis approaches available to assess results, -`Elementary Flow (EF) Contributions`, `Process contributions` and `First Tier (FT) Contributions`. - -Before we discuss the different approaches, we introduce a small example for the production of _'steel'_: -These approaches are extensively discussed independent of Activity Browser by -[van der Meide et al. (2025)](https://doi.org/10.31219/osf.io/sfgj6_v1) -if you want to learn more. - -![steel production example](./assets/steel_production_example.svg) - -The amounts we use are: - -| activity | product | technosphere exchanges | biosphere exchanges | -|------------------------|--------------------|---------------------------------|--------------------------| -| coal production | 10 kg coal | | 0.02 kg CH4 | -| electricity production | 10 kWh electricity | 10 kg coal | 10.808 kg CO2 | -| steel production | 10 kg steel | 5 kWh electricity
5 kg coal | 10 kg CO2 | - - -Note: These numbers are used for ease of understanding, not for realism. - - -To produce 1 kg of steel, we get a climate change impact of 1.6 kg CO2 eq. with the -_'IPCC 2021 GWP 100'_ impact category. -In the way Brightway (and thus Activity Browser) calculate results, a _contribution matrix_ is calculated with -all impacts _from_ all EFs and all activities. -For the system and functional unit above, this would be: - -| | coal prod. | elec. prod. | steel prod. | -|-----------------------|------------|-------------|-------------| -| CO2 | - | 0.5404... | 1 | -| CH4 | 0.0596... | - | - | - -The _contribution matrix_ show the dis-aggregated results for each individual biosphere flow for each activity. - -#### Elementary Flow (EF) contributions -If we take the sum the _rows_ to one column, we get the EF contributions -(the contribution of all CO2 and CH4 impacts together). - -In the case above, the EF contributions are: -- CO2: 1.5404... (96.3%) -- CH4: 0.0596... (3.7%) - -#### Process contributions -If we take the sum of the _columns_ to one row, we get the process contributions -(the contribution of all coal, electricity and steel production impacts together). - -In the case above, the process contributions are: -- coal production: 0.0596... (3.7%) -- electricity production: 0.5404... (33.8%) -- steel production: 1 (62.5%) - -To summarize, the difference between EF and process contributions is the direction the contribution matrix is summed. - -#### First Tier (FT) contributions -The FT contributions take a very different approach, instead of calculating the impact of processes anywhere in the -system, FT contributions are the process of the functional unit and all its inputs. -By calculating the impact of the inputs to the functional unit, the impacts are accumulated. -In the example above this would mean that the impact of _'coal'_ is calculated from only the coal needed directly by -_'steel production'_, the impact from coal produced for _'electricity production'_ would be included in the -_'electricty'_. -Together with the _direct_ impact from _'steel production'_, this is the _first tier_. - -This approach becomes more useful when using large systems to accumulate impacts into relevant parts of your foreground -system. - -Activity Browser calculates these impacts by applying _partial LCAs_ (LCA on part of the functional unit) on the inputs, -scaled to the functional unit. - -In the case above, the FT contributions are: -- coal: 0.0298... (1.9%) -- electricity: 0.5702... (35.6%) -- steel production: 1 (62.5%) - -Note that we now use the names of the products _'coal'_ and _'electricity'_ as we now assess the impacts of these inputs, -not the processes. - -Note also how the impact of _'steel production'_ is unchanged, as this still shows the _direct_ impact, but that the -impact of _'electricity'_ is higher than _'electricity production'_ in the process contributions. -This is due to the fact that we include all impacts in the production of electricity, not just the _direct_ impacts. -However, these results are compensated by a lower impact of _'coal'_ (compared to process contributions of -_'coal production'_). -The total impact is still 1.6. - -### Manipulating results -In this section we generalize a little bit for the different contribution approaches, -we call the _from_ part of the contributions (the EFs or activities or FT above) _entities_. - -There are several ways Activity Browser manipulates your results by default: -- All reference flows are compared to eachother. -- The contributions are **sorted** so that the most important contributions are shown first. - - The sorting is done on the _mean square_ (ignoring zero values) of each row of contributing entities. -- A `cut-off` of 5% is applied, this only shows results that contribute at least 5% to the total range of results, - all other entities are grouped into the `Rest (+)` and `Rest (-)` groups for positive and negative - contributions respectively. -- The contributions are _normalized_ to the LCA scores, - meaning contributions are shown as a percentage contribution of the score, counting up to 100%. - -These defaults exist to show you the most relevant results in most cases, but you may often want to make this more -specific for your analysis. -You can manually manipulate the contribution results in the menu shown below, which we will explain bit by bit -in the next sections. -![contributions cutoff](./assets/contribution_manipulation.png) - -#### Cut-off -You can manually change the `Cut-off type` of the results in three ways: -- The `Minimum %` mode shows contributions _from_ entities of at least _x_% or higher. - - For example: If the cut-off is set to 5% for process contribtions, then all contributions of at least 5% are shown. -- The `Cumulative %` mode shows contributions that cumulatively contribute at least _x_%. - - For example: If the cut-off is set to 80% for process contributions, then the first _n_ processes (sorted highest - to lowest) that count up to 80% are shown. -- The `Top #` mode shows contributions from the _x_ entities that contribute the most (as absolute). - - For example: If the cut-off is set to 5, then the first 5 processes (sorted highest - to lowest) will be shown. - -The cut-off is applied per item (e.g. per reference flow or impact category, see [compare](#compare)) below). -This means that if you want to see the top 5 contributors, you will only see the top 5 per item, even if a contributor would -also be present for another item. - -You can adjust the `Cut-off level` to change how many results you see. - -All contributions that are below the cut-off will be grouped into the `Rest (+)` and `Rest (-)` groups. -The Rest groups are only present when there are positive or negative numbers remaining for the respective rest groups. - -#### Compare -The `Compare` menu allows you to compare different dimensions of results. -You can compare between: -- _Reference flows_ -- _Impact categories_ -- _Scenarios_ (only available in scenario LCA, see [scenarios](#scenarios)) - -The compare mode defines what is shown in the figure. - -#### Aggregation -The `Aggregate by` menu can be used to _group_ results based on field names. -This is useful to group contributors together so you have fewer -and larger- contributors. -As an example, EF contributions can be grouped on the name to group all flows with the same name -(which would for example group all EFs with the name _carbon dioxide_ together). -As another example, process contributions can be grouped based on their reference product name -(which would for example group all processes with the product name _electricity, high voltage_ together). - -#### Plot and Table -By default, Activity Browser shows a plot and a table. -You can disable one of them if you want to focus on the other. - -#### Relative and Absolute -You can choose between `Relative` and `Absolute` results. -The `Relative` results will sum to 100% (the total `Score` or `Range`), -the `Absolute` results will sum to the impact score. -For `Relative`, you can choose what you use as the 100% reference, the `Score` or the `Range`. - -#### Score and Range -The `Score`/`Range` determines what you use as the _total_ to which the contributions are counted. -- For `Score`, this is the total score (sum) of the results - - For example, if all your negative results together have a score of -2 and all your positive results together have a - score of 10, the _score_ is 8 (-2 + 10). - - An entity with a contribution of 4 would have a relative contribution of 4/8 = 50%. -- For `Range`, this is the full _range_ of results - - For example, if all your negative results together have a score of -2 and all your positive results together have a - score of 10, the _range_ is 12 (-2 * -1 + 10). - - An entity with a contribution of 4 would have a relative contribution of 4/12 = 33.3...%. - -The `Score` or `Range` setting are only relevant when your results contain both positive and negative contributions. - -### Positive and negative numbers in contribution results -It can happen in LCA that you get both positive and negative numbers in your contribution results. -Some reasons for this could be negative characterization factors, flows with negative numbers or using -substitution flows. - -When there are both positive and negative numbers in the result, Activity Browser will show a marker to indicate -where the total _score_ is, and show positive and negative contributions to the impact separately. - -Below is a simple example (with unrealistic values) to demonstrate this: - -![CA example with positive and negative results](./assets/ca_positive_negative_example.png) - -## Sankey -The `Sankey` tab shows results from [graph traversal](https://docs.brightway.dev/projects/graphtools/en/latest/index.html). -Graph traversal calculates results step-by-step for _nodes_ (activites) in the _graph_ (supply chain/product system). -This is explained in detail by -[van der Meide et al. (2025)](https://doi.org/10.31219/osf.io/sfgj6_v1) (path contributions). - -### Sankey configuration -In the `Sankey` tab, you can set the -Reference flow, Impact category and Scenario (only available in scenario LCA, see [scenarios](#scenarios)) to be shown. -you can also set a `cutoff` and `calculation depth` setting. - -The `cutoff` setting will stop traversing the supply chain once the impact is below the percentage specified. -The `calculation depth` will stop traversing the supply chain once that number of calculations have been performed. - -### Sankey results -In the Sankey, the red arrows show the _cumulative_ impact of the _product_ flow -(_direct_ from that process and _indirect_ from all upstream processes involved in producing that product), -the boxes show the _direct_ (process contribution) impact of that process. -Effectively, the sankey graph is the First Tier contribution analysis, repeated for every activity you see in the graph, -making it _n-tier_ contributions. - -Using the example above in the [contribution analysis](#contribution-analysis) section, we show the sankey below. -The [process contribution](#process-contributions) results are also shown in the boxes below. - -![sankey example](./assets/sankey_example.svg) - -## Other Results tabs -The Monte Carlo and Senstivity Analysis tabs are explained on the [Uncertainty](Uncertainty) page. - -## Scenarios diff --git a/activity_browser/docs/wiki/Need-Help.md b/activity_browser/docs/wiki/Need-Help.md deleted file mode 100644 index 8d5688455..000000000 --- a/activity_browser/docs/wiki/Need-Help.md +++ /dev/null @@ -1,8 +0,0 @@ -Activity Browser supports its users through the community. -If you have **questions** about using Activity Browser and can't find the answer in this wiki, ask it on our -[discussions](https://github.com/LCA-ActivityBrowser/activity-browser/discussions) page! -If you have **found a problem** or have **suggestions to improve** Activity Browser, open an -[issue](https://github.com/LCA-ActivityBrowser/activity-browser/issues). -If you want to **contribute to the Activity Browser** project, you can check out our -[contributing](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md) -page to see how you can help out. diff --git a/activity_browser/docs/wiki/Parameters.md b/activity_browser/docs/wiki/Parameters.md deleted file mode 100644 index 91c915a83..000000000 --- a/activity_browser/docs/wiki/Parameters.md +++ /dev/null @@ -1,86 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). - -## General concepts - -## Creating parameters - -Parameters are -[special objects in Brightway](https://docs.brightway.dev/en/latest/content/api/bw2data/parameters/index.html) -that allow users to create incredibly complex systems of interlocking parts. - -What parameters actually do is store a _value_ and allow recalculation of that -value through a _formula_. While there are technically three layers of -parameters (`Project`, `Database` and `Activity`), the AB encourages users to -only use two (`Project` and `Activity`). - -Note that each parameter has a _name_ which is unique within their 'group'. -So project parameters have unique names within the project and activity -parameters have names unique within their 'group'. The Activity Browser will -strongly enforce this uniqueness and won't allow name changes if a conflict -exists. - -The reason for this uniqueness is that a parameter _name_ can be used in -_formulas_ to insert the _value_ of that parameter at that specific place -in the _formula_. - -### Project parameters - -A new project parameter can be created by clicking the `New` button next -to the 'Project' label. A default name is assigned to this parameter which -can later be altered (renaming) by right-clicking on the parameter and -selecting the `Rename parameter` option from the drop-down menu that opens. - -Both the _amount_ and _formula_ fields can be edited by double-clicking -in them, though keep in mind that the _formula_ field has precedence when -determining the _amount_ of the parameter. - -Finally, a (project) parameter can be deleted by right-clicking on the -parameter and selecting the `Delete parameter` option from the drop-down -menu. Do note however that a parameter can __only__ be deleted if it is -not being used in any other _formula_ field, if the Activity Browser finds -that this __is__ the case, the `Delete` option will be grayed out. - -### Activity Parameters - -Where project parameters can be used by any formula anywhere in the project, -activity parameters are a lot more narrow in scope. These parameters are made -to target the specific exchanges that exist within the activity that is -parameterized. - -There are some rules for activity parameters: - -* Multiple parameters can be created for one activity. - -* Exchanges for activity B __cannot__ use parameters created for activity A. - -* Activity parameters must have a unique name __within__ the 'group' of the - related activity. Two parameters on __different__ activities can have the - same name. - -* If a project parameter and an activity parameter share a name, the activity - parameter will be preferred if that name is used in a formula for one of the - exchanges. - -In the Activity Browser activity parameters can be created in two ways: - -1. The first way is through dragging-and-dropping activities from the activities - table on the left side into the activity parameters table on the right side. - This allows for an easy way of parameterizing multiple activities at once. - However, the user still has to go into each activity (by way of the Activity - Detail tab) and parameterize the relevant exchanges. - -2. The second way is through directly parameterizing exchanges within the Activity - Detail tab (by editing the _formula_ field). As soon as an exchange formula is - stored, the Activity Browser will generate a new activity parameter for the - related activity. - -Activity parameters can be `Renamed` and `Deleted` through right-clicking the -parameter, much the same as project parameters. Additionally, the Activity -Detail tab can be opened for the parameterized activity by way of the -`Open activities` option. - -## Scenarios diff --git a/activity_browser/docs/wiki/Plugins.md b/activity_browser/docs/wiki/Plugins.md deleted file mode 100644 index 3a70635f7..000000000 --- a/activity_browser/docs/wiki/Plugins.md +++ /dev/null @@ -1,66 +0,0 @@ -Since the `2.8.0` version, Activity Browser supports plugins. -Plugins are a flexible way to add new functionalities to Activity Browser without modifying the software itself. - -The plugin code has been designed and written by [Remy le Calloch](https://github.com/Pan6ora) -(supported by [G-SCOP laboratories](https://g-scop.grenoble-inp.fr/en/laboratory/g-scop-laboratory)) -with revisions from the Activity Browser. - -## Available plugins -> [!CAUTION] -> Plugins are not always developed by Activity Browser maintainers. -> Below are listed plugins from people we know but we do not verify plugins. -> -> **Use plugins at your own risk**. - -| Name | Description | Links | Author(s) | -|:------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------| -| [ScenarioLink](https://github.com/polca/ScenarioLink) | Enables you to seamlessly fetch and reproduce scenario-based LCA databases, such as those generated by [premise](https://github.com/polca/premise) | [anaconda](https://anaconda.org/romainsacchi/ab-plugin-scenariolink), [pypi](https://pypi.org/project/ab-plugin-scenariolink/), [github](https://github.com/polca/ScenarioLink) | Romain Sacchi & Marc van der Meide | -| [ReSICLED](https://github.com/Pan6ora/ab-plugin-ReSICLED) | Evaluating the recyclability of electr(on)ic product for improving product design | [anaconda](https://anaconda.org/pan6ora/ab-plugin-resicled), [github](https://github.com/Pan6ora/ab-plugin-ReSICLED) | G-SCOP Laboratory | -| [Notebook](https://github.com/Pan6ora/ab-plugin-Notebook) | Use Jupyter notebooks from AB | [anaconda](https://anaconda.org/pan6ora/ab-plugin-template), [github](https://github.com/Pan6ora/ab-plugin-Notebook) | Rémy Le Calloch | -| [template](https://github.com/Pan6ora/activity-browser-plugin-template) | An empty plugin to start from | [anaconda](https://anaconda.org/pan6ora/ab-plugin-template), [github](https://github.com/Pan6ora/activity-browser-plugin-template) | Rémy Le Calloch | - -## Installation -### Detailed instructions -Every plugin's webpage (links are provided in the above table) should have a **Get this plugin** section with installation instructions. - -### General instructions -Plugins are often conda packages (like the Activity Browser). -To add a plugin, install it in your conda environment. - -> [!TIP] -> add `-c conda-forge` to the install command like below to avoid problems with dependencies. -> -> ```bash -> conda activate ab -> conda install -c pan6ora -c conda-forge ab-plugin-notebook -> ``` - -## Usage -Once a new plugin is installed restart the Activity Browser. - -> [!IMPORTANT] -> If you need help using a plugin or experience problems when using a plugin, -> contact the developers of the plugin, the Activity Browser team cannot help you. - -### Enabling a plugin -Plugins are enabled **per project**. -Simply open the plugin manager in the `Tools > Plugins` menu. -Select the plugins you want to use and close the plugin manager. -New tabs should have appeared in Activity Browser for each plugin. - -### Disabling a plugin -Disable a plugin the same way you activated it. - -> [!WARNING] -> Keep in mind that all data created by the plugin in a project could be erased when you disable it. - -## Developing a plugin -> [!IMPORTANT] -> The plugin system is still in development so keep in mind that things may change at any point. - -To add your plugin to the list above either open an issue, or a pull request. -All submitted plugins will be reviewed, although all risks associated with their use shall be born by the user. - -The best place to start to create new plugins is the -[plugin template](https://github.com/Pan6ora/activity-browser-plugin-template). -Its code and README will help you to understand how to create a plugin. diff --git a/activity_browser/docs/wiki/Projects.md b/activity_browser/docs/wiki/Projects.md deleted file mode 100644 index 5395e0e6c..000000000 --- a/activity_browser/docs/wiki/Projects.md +++ /dev/null @@ -1,85 +0,0 @@ -Projects are one of the many ways in which [Brightway](https://docs.brightway.dev/en/latest/) helps you structure your data. -A project is a standalone environment in which you store your -[LCI databases](Databases), [Impact Categories](Impact-Categories), [Calculation Setups](LCA-Calculation-Setups) and any other data. -Data that is stored in project _One_ cannot be used in project _Two_ and vice versa. -Use this to your advantage in any way you like. - -[Read more about data organization in Brightway...](Getting-Started#organization-of-data-in-brightway-and-activity-browser) - -Projects are stored separately from your Activity Browser installation in a folder dependent on your operating system -and user preferences. -This means you can install multiple version of Activity Browser and access the same projects. -It also means that removing Activity Browser is not going to remove projects or fix any issues related to your project. - -If you want to know where a particular project is stored, check the Activity Browser console window, which will display -the folder for the current project when you open it. - -## Selecting a project -When you launch the Activity Browser you will be dropped into your startup project, Brightway's "default" project by -default. -You can always see what project you are in by checking the window title bar, the toolbar at the bottom of the -screen, or see what project is selected in the drop-down menu at the top of the `Project` tab. - -You can switch between projects in one of two ways: through the `Project` > `Open project` menu in the main menu bar -or you can either choose a project from `Project` tab's drop-down menu. - -## Creating a new project -You can create a new project by either `Project` > `New` menu in the main menu bar -or by clicking the `New` button at the top of the `Project` tab. - -You'll be asked to provide a unique name for your new project, after which the Activity Browser will create and switch \ -to your new project and allow you to set-up your project in any way you like. - -[Read more about setting up a project...](Getting-Started#setting-up-a-project) - -## Deleting a project -You can delete your current project by either the -`Project` > `Delete` menu in the main menu bar or by clicking the `Delete` button at the top of the `Project` tab. - -You will be asked for confirmation and whether you want to delete the project folder from the disk as well. -If you do not delete your project from disk, Brightway will just unregister the project, which will hide it from the project selection -menus, but the data is preserved in the project folder mentioned above. -If you choose to delete it from disk entirely, the project and its data are removed entirely. - -> [!WARNING] -> Deleting a project from disk can not be undone. -> -> Make sure you anticipate the consequences of deleting a projecgt before doing so! - -## Duplicating a project -You can duplicate your current project by either the `Project` > `Duplicate` menu in the main menu bar -or clicking the `Duplicate` button at the top of the Project tab. - -You will be asked to provide a unique name for your duplicate project, after which the Activity Browser will switch -to the duplicated project. -This feature is useful if you want to test out anything that may break your data, by first duplicating your project -you ensure that your data is preserved if you want to return to it. - -## Exporting a project -You can export your entire project to a `.tar.gz` archive file. -This archive will contain all data stored within the project like -[LCI databases](Databases), [Impact Categories](Impact-Categories), [Calculation Setups](LCA-Calculation-Setups) and any other data. -You can export your project through the `Project` > `Export this project` menu in the -main menu bar. - -You will be asked for a location the `.tar.gz` archive with your project data should be saved to. - -> [!NOTE] -> Exporting may take a while to complete, especially for large projects with many databases. - -## Importing a project -Similarly, you can also import a project that has been exported to a `.tar.gz` archive. -You can import the project through the `Project` > `Import a project` menu in the main menu bar. - -You will be prompted for a unique project name, after which the project will be installed and the Activity Browser will -switch to your imported project. - -## Brightway25 projects -> [!IMPORTANT] -> Brightway25 is not yet officially supported by the Activity Browser. -> Projects created using Brightway25 use a different structure -> and managing them through the Activity Browser may cause breaking issues. -> Brightway25 projects are shown in the project selection menus, but cannot yet be used in Activity Browser. - -If you know what you're doing feel free to enable these projects by setting the `AB_BW25` environmental variable. -Needless to say this is at your own risk, we will not provide you with support for this yet. diff --git a/activity_browser/docs/wiki/Settings.md b/activity_browser/docs/wiki/Settings.md deleted file mode 100644 index 6308e1128..000000000 --- a/activity_browser/docs/wiki/Settings.md +++ /dev/null @@ -1,5 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). diff --git a/activity_browser/docs/wiki/Tutorials.md b/activity_browser/docs/wiki/Tutorials.md deleted file mode 100644 index 340c32f97..000000000 --- a/activity_browser/docs/wiki/Tutorials.md +++ /dev/null @@ -1,343 +0,0 @@ - - -# Contents - -- [General](#general) - - [Your first LCA](#your-first-lca) - -# General - -## Your first LCA -### What will you do -In this tutorial we will create a simple product system and perform a calculation. -We will create a database and create activities and connect them to eachother. -Next, we will create a calculation setup and perform a calculation. - -The system we will create will be a simplified system to create electricity from coal. -The data we use should not be used for any studies, it is just educational. - -### Before you start -> [!IMPORTANT] -> Make sure you have/know the following: -> - [x] Know the basics of LCA -> - [x] [Know common brightway terminology](https://docs.brightway.dev/en/latest/content/overview/glossary.html) -> - [x] [Know how Brightway organizes data](Getting-Started#organization-of-data-in-brightway-and-activity-browser) -> - [x] [Have a working installation of Activity Browser](Installation-Guide) -> - [x] [Have a project set up](Getting-Started#setting-up-a-project) - -### 1. Create a new database -To create a product system, we first need a place in the project where to put it. -For this, we use databases. - -- Click the `New Database...` button, this will open a popup. -- In the window, fill in the name _'first lca tutorial'_, confirm. -- We now see the new database in the `DatabasesPane` table on the left. - -![create a database](./assets/_tutorials/first_lca_tutorial_create_db.png) - -### 2. Create the system -Now that we have a database, we can start creating activities, which will be stored in the database. - -#### 2.1 Creating activities -Lets create the first activity: -To assess the environmental impact of generating electricity from coal, we need to model the production of -electricity and coal first. - -- Right-click on your new database in the `DatabasesPane` table and choose `New activity`. - -![create an activity context menu](./assets/_tutorials/first_lca_tutorial_create_act_context.png) - -- Name your new activity _'electricity production, coal'_, confirm. -- You now see your new activity in the `Activity Details` tab on the right and in the database on the left. - -This tab shows all information about an activity. - -Your `Activity Details` tab should look like below: - -![activity details - new activity](./assets/_tutorials/first_lca_tutorial_act_details_1.png) - -> [!TIP] -> You can only edit activities when the database is not set to `Read-only` and the activity is set to `Edit Activity`. -> You can set these in the `DatabasesPane` table and in the top left of the `Activity Details` tab respectively. -> This is done to avoid accidental changes. -> -> Every time you close an `Activity Details` tab, the editing state will be reset, when you open the activity again you -> need to re-enable editing to continue to make changes. -> -> Your changes are saved automatically. - -Now, we can fill in information in the empty activity. - -- In the `Products` table, change the `Product` name to _'electricity'_ and `Unit` to _'kilowatt hour'_. -- Your changes are saved automatically when you press `Enter` or when you click somewhere else. -- Optionally set the `Location` to your favourite country, the default is `GLO` for _'global'_. - -The top part of your activity should now look like this: - -![activity details - product name](./assets/_tutorials/first_lca_tutorial_act_details_2.png) - -> [!NOTE] -> Locations and Units in Brightway and Activity Browser are 'just' text to help you organize your activities. -> Locations and Units don't have an inherent meaning or relationships to each other. - -#### 2.2 Linking activities -Now that we have one activity, we need to link a biosphere flow to it. - -- In the `DatabasesPane` table in the `Project` tab on the left, open the database _'biosphere3'_ by double-clicking on it. - -> [!NOTE] -> All databases that you have open in a project are shown as tabs underneath the `DatabasesPane` table. - -- Search for _'carbon dioxide, fossil'_ in the database. -- In the `Activity Details` enable the (still empty) `Biosphere Flows` table by ticking the box. -- Drag the one of the _'carbon dioxide, fossil'_ biosphere flows to the `Biosphere Flows` table, the value of the `category` column does not matter right now. -- Set the `Amount` to 0.9 kilogram by double-clicking on the `Amount` field and changing the value. - - Remember that changes are saved automatically. - -> [!TIP] -> You can resize the tables in the activity details by dragging the 'splitter' between them up or down. - -#### 2.3 Finishing your system -Of course, electricity cannot be generated from nothing, so we need to -add the production of coal as a new activity to the system. - -We will essentially repeat the steps in 2.1 and 2.2 with other data. -If you are unsure about something specific, just read back to find the information. - -- Create a new activity named _'coal mining'_ with as `Product` name _'coal'_ and as `Unit` _'kilogram'_. -- Again add the _'carbon dioxide, fossil'_ biosphere flow to the activity, set the `Amount` to 0.15 kilogram. -- Now switch back to the `Activity Details` of the activity _'electricity production, coal'_. - - Remember, if you have closed this activity in the mean time, you need to re-enable the `Edit Activty` toggle. -- From the database _'first lca tutorial'_ add the _'coal mining'_ activity you just created to the - `Technosphere Flows` table of the _electricity production_ process. - - Remember to show the `Technosphere Flows` table again before adding the flow. -- Set the `Amount` to 0.4 kilogram. - -The `Activity Details` of the process _'electricity production, coal'_ should look like this now: - -![activity details - finished activity](./assets/_tutorials/first_lca_tutorial_act_details_3.png) - -Now, the mining of coal also takes some electricity, so we need to go back to the coal mining process -and also add electricity as input there - -- Open the _'coal mining'_ activity again and add _'electricity production'_ to the process. -- Set the `Amount` to 0.01 kilowatt hour. - -#### 2.4 Inspecting your system -You have now finished creating a simple product-system for producing electricity from coal! - -It is good practice to inspect if everything in developing your system went correctly. -You can inspect your system in a two ways: -1. Through the `Technosphere Flows` and `Downstream Consumers` tables in `Activity Details`. -2. Through the `Graph Explorer`. - -##### Activity tables -In addition to the input flows from the technosphere and the biosphere, you can also see the -`Downstream Consumers` table at the bottom of the `Activity Details` tab, -which are activities that consume the product your activity produces. - -For the _'electricity production, coal'_ activity, you should see the flow of 0.01 kilowatt hour to _'coal mining'_. - -You can further explore your system from the `Technosphere Flows` and `Downstream Consumers` tables by right-clicking -on a flow and choosing `Open Activity`, which will open the `Activity Details` of that activity. - -> [!NOTE] -> The `Downstream Consumers` table is a Read-only table, you cannot change flows from that table. -> If you want to change a flow, you need to open the activity (Right-click > `Open activity`) and change the flow. - -##### Graph Explorer -You can also look at the supply chain network visually with the `Graph Explorer`. -You can open the graph explorer in two ways: -1. Right-clicking on an activity in a database and choosing `Open activity in Graph Explorer`. -2. In the top left of the `Activity Details` tab by clicking the `Graph Explorer` logo. - -![graph explorer context menu](./assets/_tutorials/first_lca_tutorial_db_graph_explorer.png) - -The `Graph Explorer` will show a visual representation of your system. -If you hover on the flows (arrows), you will see the amount of the flow. - -Our system should look similar to the following: - -![graph explorer context menu](./assets/_tutorials/first_lca_tutorial_graph_explorer.png) - -### 3 Creating a calculation setup -Now that we created a product-system, we can calculate its environmental impact. - -A calculation setup exists of at least one reference product and at least one impact category. - -- On the right, open the tab `LCA setup`. -- Click `New`, name your calculation setup _'first calculation setup'_. -- On the left, find your activity _'electricity production'_ in the database we created - and drag it to the `Reference flows` table. -- Next, on the left, open the tab `Impact Categories` and search for _'GWP100'_ and choose one of the impact categories, - for this tutorial, it does not matter which one. -- Drag it to the `Impact categories` table. - -Your calculation setup should now look like this: - -![lca setup](./assets/_tutorials/first_lca_tutorial_lca_setup.png) - -### 4 Running an LCA calculation -Now you are ready to calculate results. - -- Click the `Calculate` button on the top left of the `LCA Setup` tab. - -When Activity Browser finished the calculation, it will automatically open the `LCA results` tab on the right. -Your results should now look like this: - -![lca results](./assets/_tutorials/first_lca_tutorial_lca_results.png) - -Congratulations! You have successfully calculated your first LCA. - -> [!NOTE] -> The activities you see in there `Reference flows` table are linked to your system, if you change your system, the changes are saved automatically. -> Do keep in mind that you do need to re-calculate your results every time you make changes. - -### 5 (Optional) Extending your system -The above tutorial is not completely realistic, next, we will add three optional steps: - -1. Adding some detail: additional activities -2. Adding more impact categories: different ways of assessing your system -3. Adding an alternative: a different way of producing electricity - -You don't need to follow all steps, but the next steps build on each other, so you need to follow them in order. - -#### 5.1 Adding detail -To add detail, we will add an additional activity to produce steel. - -- Create a new activity _'steel production'_ and make the `Product` name 'steel' and as `Unit` 'kilogram', set the `Amount` to 2. -- Producing steel itself emits some carbon dioxide, but we also need some coal. -- Add a coal input of 0.5 kilogram and a carbon dioxide flow of 0.5 kilogram - -If you don't recall exactly how to add these, go back to step 2.1-2.3 above. - -Producing electricity does not require steel directly, but of course machines and a building would be needed. -We can represent this in LCA with a very small flow of steel to other processes to represent the depreciation of the machines. - -- Add a _'steel'_ flow of 0.001 kilogram to both _'electricity production, coal'_ and _'coal production'_ -- Now recalculate your results (step 4 above). - -You have now extended your system! -If you want, you can add more activities and flows to make your system more realistic. - -#### 5.2 Adding more impact categories -LCA often compares products based on different categories, not just climate change impact, -we will add an additional impact category to measure water use. - -First, lets add an impact category to the calculation setup - -- In the impact categories and search _'water use'_ and add it to the calculation setup. - -If you don't recall exactly how to do this, go back to step 3 above. - -Even though we just added the impact category, we can't yet calculate results. -This is because our system does not yet have any water flows, so the impact would be zero. -We will now add the water use biosphere flow to the system and then re-calculate results. - -- Search in the _'biosphere3'_ table for _'water'_ -- You will see _many_ results, we don't want to search through all of these results, but we can manually filter the results further. -- In the column `Categories`, click the funnel button and write _'air'_. -- Activity Browser will filter all results in the column `Categories` for entries that contain _'air'_. -- Now you have much fewer results -- Choose the flow _'water'_, _'air'_ and add it to the activity _'electricity production, coal'_, set the `Amount` to 0.0001 - -Now, we can re-calculate the results - -- Go to the `LCA Setup` tab and re-calculate the results. -- In the top of the `LCA Results` tab you can `Choose impact category`, where you can switch to the water use. -- You can now see the impact of water use on your system. - -You have now added a new impact category! -If you want, you can add more flows and impact categories to make your system more realistic and assess different kinds of impacts. - -#### 5.3 Adding an alternative -LCA often compares different alternatives as well, we will add an alternative way of producing electricity to compare the two. - -We will add an alternative production of electricity, based on natural gas - -- Create a new activity _'natural gas production'_ and make the `Product` name 'natural gas' and as `Unit` 'megajoule'. -- Add an input of 0.002 kilogram of _'steel'_. -- Add a biosphere flow of _'methane, fossil'_ (choose one) of 0.01 kilogram. -- Create a new activity _'electricity production, natural gas'_ and make the `Product` name 'electricity' and as `Unit` 'kilowatt hour'. -- Add an input of 10 megajoule of _'natural gas'_ and 0.002 kilogram of _'steel'_. -- Also add a biosphere flow of _'carbon dioxide, fossil'_ of 0.7 kilogram. - -If you don't recall exactly how to add these, go back to step 2.1-2.3 above. - -We now have two different ways of producing electricity, from coal and natural gas. - -- Now, add the _'electricity'_ flow from _'electricity production, natural gas'_ to the calculation setup. - -If you don't recall exactly how to do this, go back to step 3 above. - -Finally, we can now re-calculate the results and compare these two alternatives. - -- Calculate the results -- Switch to the impact category _'water use'_ (as you did in step 5.2) - - You will see that there is some water impact from the natural gas-based electricity. - This is because the steel we use in these activities is made with electricity from coal, which affects the impact. - -You have now added a new alternative! -If you want, you can add more alternatives to make assess different methods of electricity production. diff --git a/activity_browser/docs/wiki/Uncertainty.md b/activity_browser/docs/wiki/Uncertainty.md deleted file mode 100644 index 930b261ba..000000000 --- a/activity_browser/docs/wiki/Uncertainty.md +++ /dev/null @@ -1,54 +0,0 @@ -> [!IMPORTANT] -> This wiki section is __incomplete__ or __outdated__. -> -> Please help us improve the wiki by reading our -> [contributing guidelines](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md#wiki). - -## Uncertainty - -___ -## Monte Carlo simulation -[Monte Carlo Simulation](https://en.wikipedia.org/wiki/Monte_Carlo_method) is method that relies on repeated random sampling of data to produce numerical results for uncertain input data. In LCA, economic and environmental flows as well as other data such as characterization factors or parameters may include uncertainty information (e.g. mathematical distributions or pedigree scores). During Monte Carlo simulation, random samples of this data are generated to calculate LCA results. - -In the Activity Browser, Monte Carlo Simulation can be used. The **steps **for this are: -1. To create a [calculation setup](https://github.com/LCA-ActivityBrowser/activity-browser/wiki#creating-a-calculation-setup) and perform a (static, i.e. non-stochastic) LCA. -2. Then the user should go to the `Monte Carlo` tab -3. Then the following settings are available - -* Here the users needs to specify the number of **iterations** (we recommend to start with at least 100). -* A **random seed** can be determined, which can be used to reproduce the same random values again. -* Finally, the user interface provides the option for **including or excluding uncertainty information** at the level of the technology matrix (technosphere), the interventions matrix (biosphere), the characterization factors, and parameters (if any have been defined by the practitioner). - -![overview monte carlo setup](./assets/overview_monte_carlo_setup.jpg) - -An example for Monte Carlo Simulation results are shown below. - -![monte carlo results](./assets/monte_carlo_results.jpg) - -___ -## Global Sensitivity Analysis -### Overview -Global Sensitivity Analysis (GSA) is a family of methods that aim to determine which input variables are contributing the most to variations in the outcome of a stochastic model. In the context of Life Cycle Assessment (LCA), this means that GSA aims at identifying those variables (e.g. economic flows, environmental flows, characterization factors, or parameters) that due to their uncertainty distributions affect LCA results most. This provides the LCA practitioner with a shortlist of important variables for his model. For some of these variables, it may be possible to collect additional data to reduce uncertainties, which may then reduce the overall uncertainties of the LCA results. - -The **AB implements the delta-moment independent method** to calculate the global sensitivities. The approach is described in detail in our [scientific paper](https://onlinelibrary.wiley.com/doi/10.1111/jiec.13194). Our implementation uses the Sensitivity Analysis Library [SALib](https://github.com/SALib/SALib). - -Here we describe the basic steps for performing GSA with the Activity Browser. - - -### Step 1: creating a calculation setup and calculating LCA results -[How to create a calculation setup](https://github.com/LCA-ActivityBrowser/activity-browser/wiki#creating-a-calculation-setup) - -### Step 2: performing Monte Carlo Simulation -Monte Carlo simulation needs to be performed in order to obtain sampled data for the LCA inputs (economic and environmental flows, characterization factors, and parameters) and the corresponding LCA results, which, together, form the required input data for the GSA. A description of how to perform Monte Carlo Simulation in the AB is provided [here](https://github.com/LCA-ActivityBrowser/activity-browser/wiki/Monte-Carlo-Simulation). - -### Step 3: Global Sensitivity Analysis -Now the user can go to the `Sensitivity Analysis` tab to perform GSA. The figure below shows the options the user has at this level. -* While the Monte Carlo Simulation was performed for all reference flows and impact categories at once, the GSA is performed for one reference flow and impact category at a time. This means that the user needs to **select the reference flow and impact categories** that he is interested in. GSA can be repeated later for other reference flows or impact categories based on the same Monte Carlo Simulation results. -* The user can specify the **cut-off values **used for flows in the A (technosphere) and B (biosphere) matrices. -* Finally, the user can **select to export both input and output data** to the GSA. If the user does not select this option, he will later only have the option to export the output data. - -![global sensitivity analysis setup](./assets/overview_global_sensitivity_analysis_setup.jpg) - -After the GSA is performed, the user will see a table with all input variables (environmental, economic flows, characterization factors and parameters) sorted by their delta value, which is the result of the GSA and characterizes their overall relevance. Additional data and metadata is also provided in the table. - -![GSA results only delta](./assets/global_sensitivity_analysis_results.jpg) diff --git a/activity_browser/docs/wiki/_Footer.md b/activity_browser/docs/wiki/_Footer.md deleted file mode 100644 index 153ff8e07..000000000 --- a/activity_browser/docs/wiki/_Footer.md +++ /dev/null @@ -1,13 +0,0 @@ -Activity Browser is a __community project__, we rely on __you__ for it to be awesome. - -| Activity Browser logo | ❓ Need help?
[💬 Ask the community](https://github.com/LCA-ActivityBrowser/activity-browser/discussions?discussions_q=) | 💡 Ideas to improve?
[💭 Request a feature](https://github.com/LCA-ActivityBrowser/activity-browser/issues/new?assignees=&labels=feature&projects=&template=feature_request.yml) | 🔥 Something Broken?
[🪲 Start a bug report](https://github.com/LCA-ActivityBrowser/activity-browser/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml) | ⚙️ Want to help out?
[🛠️ Learn how to contribute](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/CONTRIBUTING.md) | -|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------| - -> [!TIP] -> **Activity Browser** now has an open beta for Version 3🚀. -> -> The beta supports many new features such as Multi-functionality and uses Brightway 2.5 under the hood. -> Help us by making Activity Browser even better by using and providing feedback on Activity Browser. -> -> Learn more about the beta -> [here](https://lca-activitybrowser.github.io/activity-browser/beta.html). diff --git a/activity_browser/docs/wiki/_Sidebar.md b/activity_browser/docs/wiki/_Sidebar.md deleted file mode 100644 index a2d61f385..000000000 --- a/activity_browser/docs/wiki/_Sidebar.md +++ /dev/null @@ -1,65 +0,0 @@ -### Navigation -___ -○ [**🏠 Home**](Home) - -
⁉️ Getting Started & Help - -- [Installation Guide](Installation-Guide) -- [Getting Started](Getting-Started) -- [Need Help?](Need-Help) -
- -
🎓 Tutorials - - -- [Your First LCA](Tutorials#your-first-lca) -
- -___ -○ [**📚 Projects**](Projects) - -○ [**📒 DatabasesPane**](Databases) - -○ [**🧾 Activities**](Activities) - -○ [**🌍 Impact Categories**](Impact-Categories) - -
🧮 LCA calculation setup - -- [Overview](LCA-Calculation-Setups) -- [Scenarios](Flow-Scenarios) -- [Parameters](Parameters#scenarios) -
- -
📊 LCA results - -- [Overview](LCA-Results#overview) -- [Contribution Analysis](LCA-Results#contribution-analysis) -- [Sankey](LCA-Results#sankey) -
- -___ - -○ [**🔁 Graph Explorer**](Graph-Explorer) - -○ [**🧩 Plugins**](Plugins) - -
🚀 Advanced topics - --
🎰 Uncertainty in LCA - - - [Uncertainty](Uncertainty) - - [Monte Carlo Simulation](Uncertainty#monte-carlo-simulation) - - [Global Sensitivity Analysis](Uncertainty#global-sensitivity-analysis) -
- --
📈 Scenarios - - - [Flow Scenarios](Flow-Scenarios) - - [Parameter Scenarios](Parameters) -
-
- -○ [**⚙️ Settings**](Settings) diff --git a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_1.png b/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_1.png deleted file mode 100755 index 0f31ef26125f40879a69844076ccf07dc02630c4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22913 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 S7B-0;BrTyJUh>Ys@4o?n&(|~n diff --git a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_3.png b/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_act_details_3.png deleted file mode 100755 index 8bbf6ab2974fb0506bd60e8fd48685e741496019..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31106 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 diff --git a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_create_db.png b/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_create_db.png deleted file mode 100755 index 029e62ecc7eb70e9850da07f885b7d576b9201da..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27936 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 QU7vVD>R!?*_}xD8UxfhKU;qFB diff --git a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_graph_explorer.png b/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_graph_explorer.png deleted file mode 100755 index bb922210da5639f508ad11c70f9b4b92335672c3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22623 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 diff --git a/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_lca_setup.png b/activity_browser/docs/wiki/assets/_tutorials/first_lca_tutorial_lca_setup.png deleted file mode 100755 index eee28a897c439f09faf3c38dc89e69c99339cf38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30619 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

hj*c*Tg|%DWg>G*`$N*aTxJl*{|6 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%0DfD@FhTaGq!!8RA#@tW;NR>0zE!r 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 fyWU?*K_ULIS5l$Vu^~e5`j-ioh!m~As diff --git a/activity_browser/docs/wiki/assets/contribution_manipulation.png b/activity_browser/docs/wiki/assets/contribution_manipulation.png deleted file mode 100644 index 4beaab453e38c49ea6c444e5ae2cc2fe3978d605..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43335 zcmdqJbyStx*EYNX0Z~Fkx|9@YkdjgXK~g~J?(S}-M7kRkq@=sMyE``B-Oao9@pyjc zdB*$4`@Lg)V?5t?k0G)*`;N8dTytLYn%CNXA0&m*9uPc$Kp<%EMFeFa5JYhZ1diuE z5_pF&@`?!jhhib3Xbpj&x4{0vMbe@ZLLg5e?*-p{bWq!#wO5g|y=yIt8>{sqSm_29Z|Hy;`H62+`YBbGK#Zj{g(1(xg)Qo7n&kjbgpk9 zjFmwTkKal_``bO+cP?D&pw^icC+Br_YtGv&tgNh<|2eh;5&wA+>Kh3!`=3`P55$E8 z{~Sd$OxXYYsWZLd!cNRMi!Q^jt&uR#Xn=9=zkK^g`nB;6|*hu>)(e+ z(*Na%5mUAfdv-esMm0>OJP=HNqiTnRa)5RzER0DShQf9gxd? zIvz@XymR*A^XJG54M7V1{-iW5?{5^;)Wf;@<_U=p5623A{7lP`%@xI7vVmx6YhUc$ znPzM9C4~ilV1kR|$!UQEtsoO`5 z7i51Qhm88g4KK;bJ#X_dwC-*Z#?kOuSy)7*q{@G-v`rT4Pu6K8kN%QlD>GT_iDiX2 zI65niVij-k@y*HErXC;Lq@<)+KA+bx@mu!K%pDB4Ga(ZWB5LjJ4ICur|GCTBUtkV( z<0IgT<8?k;Twm{?y}2C971Lj`fzL)9%+$Q6{m#|IWR|V^Z2N~$HhTccMJS)aquLH9cZ1SE7S8k$(Ya4J^IAsvscKGrGs`KQSe*9kk*_90<$qLW3s>oZy8j3q`W z4SrabH3zH8*U(d9$cW1#4dk-^^Z_zD(XuaT@hS7pOer@6?XAGMRTqWTD@(E<^u+4N zgoF=YbKOB8PN(aAceiJ>?NSNzzPa(176s_CSI0Z;jP=(|?rxW%^+80iapfqMCY)?> z9L|PO#`OB}9EuoT9&f(y#_7#n)zi^o(q5&*VYm#!vb2nNF8u|kHz@Q})D|3XgZS|7{tEU_Hrex?Ysz5SA|g*{^zwe~w^hjpfJPiVxK_w6D3$8>QP=&^e%K z9-5Hw*7wwuere9($W$&@O<3_ z$;}b;`;x%Q=~d|CSnF4RdU|fzJ)R|-0TB^lI-1_AHk>N)qok`%5kAY)(bFq3n?=fY za(k{&@U%CczdtH7H-XoAOh&!Z2)mAtZ#;+3WGM3oB^{!LrR9%L-Kz;));>4L=d zIe7}+x{u-yd3t$8dLJFF+~k>bAOF11QNSrFXNMSL{o4<@)LAN}Nd4sot@dJ3N|)4< zErXTD_H_xDY$2uIf?%!iCG&W@<)7+z5Sqg)DvN+C&!qPxc=}IHCB;&a- z>})wgJ-@%_awgfNtf|C6nqyI_ov`~tZz=#^{6g#3N`*-*i-}5oTT$|QYH&o^ah}6* zs4xg8)Kt#mZpP3u`eMC)-PW#Ao1G!QC2Gk6>+w5>lgQ}8;^Jb0t)(SxTU13TqYm#Y zNex{sEv@%bQfpZm$!q~VR#d`Fv>azlX3s2H%cnMBPUdoa@Z9cZ&wV6QZI)$kyV4rA z^pr|X-ZXwed@m}RVtN$>Y!#)(>omWhsdW_f90`p?6j-Cgc$%vH)1Tb4%g_E$T^+Qg*8**JeJtZf3l`LHy9lTc; zsVbH2_kT%yQg%lTmWok>1%_bJ>z@D_8e$+6i;Ct{MUe+V9=L|y>aii0ucCwVq#3st z@@#X3cT3>1UbgRAJiG45dSQ-I?br(@)p|DsLqo$7gE>?WLsmVj<8@{^I%RQ0cXx== zOY?VEL5aoX(FV4?VgrBT)GjracSMMsq-G5V=ubnP5Ts60t-eN)Bbqi^jb zv|qO`Qa?Z3{^9YxBDo(_l$5enC8S{esvPNd@9(|Ipr^>| z>XpNwlC|9AJ9=q6o?Co*xASY%_~5Af((&FzfhHCf(IsF!yw z-RO}#jFE(eQ%MyDgip(sWD?>ss4ztx z^_JkX-bY6%cCp=^LYS|ccuTe>hw8%~PKogOIbeOOUlUA&W#JW*lDO3YnS z?i2yVF+v*Em^zNFIlJ`*%O@~J$X{k}vg-9|cXAwWe}dx?jYh}r z_LP1Em)awFXVC>x>m$uSJBV4k z-E#NZk%U&+=QG0PQ9G%7k=>0`_K_Ql-IG1kU|6Q5`Iz}=sV%6^P~%czvWQN#CgcE% zip6@&gh8!FBvzPorPdx=xIJhfmR~y*Je;wlSmuH#7R8WipHC9i@T5p?)s@v5U4nq4 zskN1>y(b$6VBBwQz3`zuFx19#xlSH%rGwg=WNgG{HquTiQ!0v$<60hrGHK3yc637d z^5s2wdED$mEA3||o{Hse2v7!<7UTS5*0S6QqlbQAwHnhw!|5pktjtau3s6(IeHx{Rc!utM^&FY;~ykJPMFRTXULX8KI?2_{Kf z&Beo*myQqi>TwNClnp0q@S*f-P2VE3&v(k97aCqC=U?}ksH85!MvcdYO5ST{C;b^| z{pVf;*+RREL*4ZLYah`__)N9hxI$CQTHzwfJDwh85xf=MY~MIG&;64F&8C)qOB_9t|CEC@r_KLQdNY3#vl>q7HUq=v ziZwbk?l-nxvBUN3=%LQrnzL^govf-yJrfg&d*gW9mub|8GBsP+oL8$VD)wiaEG?SFEPuwAUuR19O)*>aO1J(8vP> zAQQFv81zb|n#Uc%2DahCrz>q_t=Vqo%et z(Dd5H_28&iNzv*0+;WxiJgT4o$sy{6E*{N4r&*m%@Gu4X`)fn%5NtLl8Yik4!18=$ zzu6H*FC7x=mywZSDN^^!Vg1pp^YLn8ITU!KPunTIYq<^$7T;9@qP0Fqr^$u%WMqdQ zE^p3e0f>w`KOb(B%L8@F)sn3N6c0ZvzFN)o*s!oB*?ykxiz8eEXdW7AfCGldafuBBC1q$_oDuJ{ zXc<}AN}G+xh8u=$WA#&=;Td>L7LV%p<^*297NT;wL;(ml%1u=C0U8;>V4*9wnp2@v zP%L=Lf-CTpKhspx)oQESr&q)GeQ@S-#P`oHqnN${<6j~1JfiUa^kl-iYr{Br$rVrHP zIGpExB`Fh6muruo-hC(&$JgFcH{Gs~dM_cNdz+y6#L9N%27|wB(;dWReEe9Ot*O>u z3}j>&tL_|cyBsE_J*zfPUU!<;Z^1*OS3?A?g75n}n41R*nB;IN+ z`&}N0nb2&eS}sBJ9G($d-}tz1%mkj6mNva}!m8ZOyi<;H{pWM9d!h~wc++%>RRI}W zJgw&SH)bZ9`uh5GE1ijb^`xeAkA;@^%d_NiU${D*r3mNqsnj@xzV&`!35vFKw-YvV zG(tNG2(b6>=ZrC6k1|)*(vs8I+}t$!GldW1bD1QV;w>%hn4>QCQ$w$>hx;okDi$|3 zfJag1G2{sf-!-(83*-APZEuUGa1AQdrTJhkz-kt&l^5Fn6&2yv*WK<0qeY0(VPQE= zu-ZSt9Rku>@rYCXL^K=%az8l_K-crRY^I@fpnX!f=4@i83Mm+9m8m{@A3 zFKsY-Lw9b6*)5+#kzy(PLM~7A1_`t0S`P=3EtkJgsYjprG3{03j|CTY%g2U>@}wI8 zK*FzFBj9m4<)qoQMytC@fU-m~iAOhUcH<;~L8YPrRE!4)oe$~(><&vx>g$wM98ci6 zcv!K<-$wB}j^X&9^W#?$w3?kmQ9 zl&t<~bM#b`Sm6to!zZAc(&Q0JnmBaAkBx;)OzYk_Oz7{?#DmadVnS!V#yB7N!?Uow zd@{J7Hyn&}LW(a9Sl|ek?qwOAh^VMm&xGYjHq^tT!RV#QArSxrIz&hfO+wJY%#9-$ zcb1Wy+n>p-{a#YCjNmBw?lwB4;6A9``nEiMREphq8wGs#G4AV(-`(ne!Uf=R+AlCL z?QY96Q*{j=K6~`LYkQvww62+RyUL22gYoQ9h%LsW$2g7QQtIw^>azl{Xg@;?DhU2Y zlk}iYolo30>yOs@6Wd2uE`tXX`Q3vAd@-<|*w`ksVqjqL--VqCU$C&U7=TobfQ+FF zt-HB*?_#M8JVYAq{fNF1mGXvrNa|Vw71|h zEdzsU+d{n3Ha8wvP$tw0IfE90I$v@OO?%DXA|O4=3|9P&Q>x;Yinl=I7O*@ zwI_WR+dIS-mX&thQTca3x?Eq!LiVJUl9NAJM?Aste-PubtI`u>1W+?F+zsDpsH)Bk5jvQfr z$x@3TU%do#itT*ONz84V=AjKo<$jAl>vYV54yOTJuYHwXx`xdpbA^$Dc&dD5^fsSW zb8!unTVFuH3yp|qSqrDaB9KXDq;fZly;~*!>+TkjL&{8=Tka>>M3t$X!*3p16)AbyL~Ra-2;3w@h7MJUl1 zW@v33EnAB~_7%p&MdHX54<}d&$R&*9zf%TmuVSGMbL0y|$o|_cxtAt05rxSC+I=~( zKKSx0c4lUK&mes9oPPb(Qa=C>Xt_B;lTv>Nms)L!@fVg*G{K2+iV+8RLypn-kZ~_q zS-PAkHK(!?yRqKIF*|P3q)kU5D^3}B=S#DZkjU(DrtQFIFuVuo0O`CL?T@*S4T#)i zTfbp9x3)S-jeU_sgcA`1gs*yVbVf#@S9m!2?(S|I3#dhZX6WN}J8Erh)$+}=Rh_I0 zMQ*ZL+S{*n(}d%(>CyGGnSRO8r?K0eYArunlVQz7nyt252$!P1&W4v|l#(JNYjV6u z<4fRoZ=5KbC~|;fsk(tFTOOdnU0zzj zs`U(Di$%$#7BG}UXm@{qFrY|ZkNF~|wgrF?Sr*}VJV>P984q=vmWX8uhR7q>V1`z& zfQsk(#y+6XMflf<>FdUna(nZq80^mT;-W}PDP=1(OtU49tYG>T;H4nAxcY#@Xp3OX z2#bjkIanN1mQW3yuB@!}^~k}f1hVz4A-_L3{s;TVK+wqml3& z)$-5sSG6WYc47?n=ci|cwY+mbUXQtbFQ;W|b;|!mYvNj4q}x?sd-QH`aq+og5yf=b zO-p|LZQBGIIhXAVWAEJjV2lvkX1CQI{IIaF{HyzuHyw?QpUZvF5a(wqk9>ANU11Ql z^!J~|jmpTAluuWilcJF~aaqhmJVw=%o^I8PRaynZXe9_BS_%NH$w{#*D5u`B8mp_K zmCs#WAJpziIE~c3-QjeTR~Y*}!?poCJiWa;y!T#P)avJ>S2UhV+b@y4g~7$52|6Zv zGZsu-`z8gw2||I+j*j_qb@#Pv?;MR^UzIW63JBo8atP6U`I91JKAzmBH#_bP+jGD- zfQVxaf~bs)jKSRXRQ%Lctf#<#DayzMH7_ZUO#=v{(gm>N#|oqPM32G z2%wgQ-n~Q8vnC@Up#msDs56Wbpu3gfgXF;sc`2C7;`7=O;WDcIl-QC(#pfBYXjASE zZg?w5!SUcp!DOztw>BJdg4q$mYsY=ASIeff8UDD8VR3QaeA`P&2cn-#!*@*0tvVgD zaFm}tsX#jdOSuCI$^pu;>wyrYrluB{f62y$hf*wxVt4udj6Y7T-a}91H$!O@->OvIl~JgL zsljA>%UCoXI-y^seuHlPCb zJU%`)emguelD!1H&%yqD8r4x7FWKc49I%z)WD%F?l${1}@`*}o+}POtu5MRQ_*3M( zc}^>r*L9?BSJL!Ra5wV$zIbWVN3%AXV15F`hk85jljEFBpF6GeU%&M(x}9O~>YkaI z6IdRbt@Wz}@6tVtkWH6SX>?VAhU>d2vL9iQ^l^ESWUWoDeCvzWg?Pa3IIXVvL6= z3Fx*khgH-$HMIaD-l(uN(V-$CYrXzNVX%JaK4&B+GxFb7^s;bpe8c4cZQM|2Dt@;+ zniZ{Je`69^W=9bnD?T(He;jspvyNO5Ocj-c_aaP$$}R2f2pUz550if{7spWO*&|q? z8`cVAm-Mq_LSj1;DvvTriCntdx3smiKout;=)jpK8u_x!JWKBb+AU|y$=k$))&5vr zIye@SriS6D23 z85JY6+nWp@#mJ1gsaIrlo~kyl|D+L@kkCWwe(l&xXr-#6vTtem3^aTIgzJxZk3~-_ zyR(z$W}u;x8%Ox**F9KA3D9bR^q8S8`qwu@IP5uUexBK;{WKWpds;#cY%F}0rKHw#MD2B8n3vV;ik%3W$v1yxf)zQn&S%nD zGGo3;ot>R@%5`60xge=l^d)HUrgcYylv+xooGzXO8|R|kN;|I?tbowzj0VIg->BIm z0#QKyf+`K2n6IV3J_dAjnsrzfG+7q5ww$}MGs(_m0IQJXo=20~=F$XEUj)5!6aaa# zCxq-Q>nCtg*yI2Rp*q#_n_~q3G_RL;EHhILBI4}=?&h$%@oL0z^L=xyX8&Amj$(CC znm?|Rk!`XgfrsrD$A?-awv;lnm0S*A#Y<3B$6rriefp_7&EBJ45JFOWHBf4;4|=IR zVQJld+>T+)seEMt%C&a%p?kIcvQlGu01`UJ#ZNnbo&Mo`9|kw~@oF8};|jJ>)ecGXwPds|b0Ux#5n(6BiKOoSE-c82zq9qRhs zuh9zHcfl84RDp!jfE5+%ZEf`~Ocg$yyCSt(XhMwRg+`2z6Lrr}pD80G{dYyphyNnY#G$c_xa0lotn} z|A+_I${izA{&&pkRJV%-M_Woq{d*l93i5XWOi@CD%Orrw!~hD5dx(hm%9Sws)NW@= z2+%KCDi=o2DgL6f!k~w)-Y^=FN9BclUeE4q^<;%5>%l=r;Eu|{@o~z>tgr`Sf3cX$ zdB4d+c3eC>5ehnGIq*qi}5O9`Wacl)v zJD0=$Q_u~tw6I`Gc=P8x!R6hq=!TaJ3_?zB{8@N20OOYdKci4& z6I*CIJv2P*XD|~H%txa8H}eF{-(d?hF%n{8+F~PeZw0mpmCK(H5HzLtDg(g^N0}8R zC8akalmT>DC3!AvRAKGp+DkNtLUaCu^fQ%Fb?2!kH6u!+6QO+*xY_}v%a+~*=;r1{gycj z4uCz@sKZfE7z?7i#{)<nVNZY>sE2ELidcV0|h{)hgMwSf_h(4;KNir%-4WsoT@?K78Y7s2gIlJrihk3kTB^03H zP!2gaST^XoKz>9>XdLU_IzFuT0q}i^=M2=8Br^h=CGglj@aPP$Z|ENOkD`xTNuc`U4pz_tz|?|ewsO-=*zLHg zc{o3?AkcP*_8)mp8T0%}V?{;PK()R-Lq;02^(x)vsdJ!H|JVv>mZk{!+Es8?Guh9% z08Z#WBcqhj$9yxMUQ2DORM9|3kq)QMifTq_MZ-2a(%V;&VHJP8XLEL6P+Sxqw(`kJjjB>9K28@F(+=sZyTH4)TxLa@Zamb1w0pvd0HX z#q{CfUlf7TFCBPlYI_i~+l)eXrM!-g9=wKmbY$~1Hz!2VdAhBwjm7>JKHZHO-`P2S z<@nE_$=G#)SnM||2mAmu^^)D03A;It$3s&i_V*j6r@JSL{D3=0G2Wn4Q&U4o80CZJ z7*AL3%J$~3G9>_vg5U39wY_gX;nf?T3WyAN7jE~wQOjDVewl1L;*^xsEHJ{AF5Wg$ z_gfU0BnKwzVdKmAhD|%KUdIEN-NFar!65qrrI17IoiPLuiJmb15}6gCfl83SPg+Y@ z+FEK3MKC&Sw9cJJ*RvBbZm#+Om(5sOg5z{Nq=2M}Mp0O`AdmdQ_X6aFQi)OXpmuxa zjUatn0D&J^T?rX~H^eb9EA@A))7(JPa-6b1>X|IAsI0{AW*;ul^a9eF`#CE39s?m7 zVhRsn9RN^m`ALZsj2sF`b8v{a`dc6BjA`#Wj@C>bfyyn$(>E_~QMJ?w&Cbs5rST~d z1T^AWK-ms(7lUe*&c!bfFrLzWs13brJOLh#9nGMswUE;KF;~gEURw*+lx_Aw*F3K# ze=gnRnV;d-2p&4-{DNYM=AYoaiw~&z6Q)?uPO`o!N+$$8rI^D4nbN=1F#5R$Js4_g zNpkJ-O~YZ7VoIaWvZFPdamCZp(wKoVY%Cwz5t=Ft>$~9NrEV2?>p;;r0gke5(V~>1@{!KRh(_ zv%fzpYlc)e^U(|Qd_r?~m@&)D=0sFfh`@UZZJL7 z&!0LfDtWWkpZ*wN*{r&FgwYqITT4sofE6CG_mYhq7(a|b$vTz`Z76?;doV?N6JSb< zKFLTZTHp6#Kz7pHsyvOmaxRutANKTtglx;b96k!0zyvB)HZ<)WHO7NA_?|vBjeFH+ z1%|_V35kh@PcY3kuFlUxA|k{$hTO`r-*R(u&TnmrIXS)3ZgUj{TDtv%)79(BBf$OZ zn^t~!dX@Ge@K9oh@-oGtHfdX_qMa}ii zzIRV^-ejh^&CkCHBEE_rO1q(BQ?3Z#08-PBuqCS$irI7~DU5 zo3)^+Sy2_FaTG;wGEo8R&jWIKVkr1NhW6YwqIevS_Zif`hndy9PPxX*>-^qj-8mUV zZB7kz`*m}X7!<=Q!l^A8m7d_@;x>z8R$|rP)y0>TbdO}qk55$nNaVIJay~PIM?eV7 zHC{fu_#2e$Qi9%1Na>XI?HBWnnXJ^pNLuC8)Qyos&q5bj0Qkid_Gv)hNhCR1ZPkz3 zNE&dP4FBgMnB847o)hBg@U2+!)~xxx{zGh9$IYxMhp0oHwLDdqi_w=nJUQibum}Re zWLw}}z|Wr)4C<1pKy1X}a#aI75_3V5PQ5S;bS+Tb*XXm|fKuuwU`Ao3L#lAFG*HC= zsUap7VfR?>AP|hK`>fTG(a`t-=3{lGj!~(t3YWeMRz!3TtpJ`>8XzCg8;sFhoeeF0 zwTpUia8Z7j`!yFda^b_X9P;E1lDMqdr8KEtfB5hS>VB76=%RYz>h?PjhB|kqf6nss zM9|bkn<%3EHZbr=`zoE?Jcx{phv;g%ec~uZtMx&SN?iok(Q2XLFpgetl9-hh%kHjF zmQv|2-Nvu?Xh{IDUPf_ zaQO!CG)Z|!5rw?~0sSjpND>R%+LLbspd?ycUbc~NxK&%W zKtd%F&!tYL%6w;wguZS*T2Gjl6Z8hkJ=Q^(!W z-x`J)F}v9Eqw+arSXPN=H~-;gu}@E$AMEF+Yi#opb73Lkh3!uKnvfYK5Vd2HzrzEG z^5%^HL;hTGTTzEAV>y2{;8K2k%8qI~q^DObiWtjP1~Qk9z5m>RT~*V`8+27c2SXcN zv#^l)iH%)b^YUkqdI5y((PIU8ou-pUrN$;yl9+pDtV7u{#VhoGF>?e6J$8D;XKzMt{;C#~`g{wm#mNBG|gj>FeZ zM>`8IjVGrR`XYgfoQ!~DB{f(WU-|bbhaP_waTq_BmvzDzC}+n`;Xn?ipr#CuNH8(E z*#f${cmNU~ecXDV?#A0Yp|@LeG2(P+czdx%sx9_c6kGoBofqtpAkhLSj+2uUue;JN z*4tw5>KdbKAc^HW3<34^$e)pqkk7T6;K7~bWh zPFaUiXg4e_F)9DP{=#=`AVtCW;dPIdG?=Kjdau^pWaa%hN$t7QC2CmMZhvHq{bPr@ z&Y7#xsLbast}g2Xb@ZTN^0xHXC&gO3+_byVk_}iF4Si>N6;#`>8#vtKzdgnRp`h)V<4}dPfSwzxe@KBvT`XI?4k2$7pXsG|lGCN)BGzM|kYF19 zlJwmuQ0aJm)E7tyfSAz--Ps?^(}!9;(^s||eRZx^xqX9It#zQe+hA4W+)n}!vc>s6 zsd&;MCFqoY{3VlE@U_i_&GZ$RH7?NKD18bp(QReozqbKY(6H7!&@U*C!hurhV0F5g zzP+p>Nm^y*eT{1keY=}mT>>X2a>K@OMlACYp${s7$Y*#b$nStlqIO9b`n5w~9<(CG zlbOROtyiT%o&}KWy@P@$x0=x84^?HQQiHyfy(lKrHkcx~)8Ex;xJ<$ru#y4IM-+U| zKIhFk$$$nHD*o{+;K@C4ywQBFSJDlQpC40-4&05pCGgq@;j`)wrYU2%eVzM~ot=H~ zQ4Y4k-{~QlDwU@IAOsR|M@-?8NOeLRV0>SqmxrlChXW@rL8}~pVp{bkX9Qp}5R4&h z?x$S0x3@s&R(|~V5Z=Ld-H^PNSF^DJObG~b-93uM2aI=|Skx|}v-(MOdMauw32V#C zf*CQo9zsl~YhH^e+ynB&YF&*77e~Ca0rdIuzdq8G znO;p=xg0LP?~0=D26STQkZyM@_Z4AVU^2iP^{F3zyoJpckOp>HBK;N3Ly?n7I&m=_ zJ#2U8{5#cMIve2fnca6yvI|DWh=_>|kKP)O)R43 zRI)&T2WzQf1s9l?|N{xn?$P&OsWpWgl$IK@*{syQW2s@|A;ZHC_uham!b$(rZ(9+VvWZb6$ zTW5={-v;YkmDLPpAAk-*pzPuHxgVCx)eadBz1rK)@aQ%M(4e55=>-4{h~o4S6CV^rq7#=|IGu zz~lHhndq|>Xc7e_bB1b{{RYmSQnWDVga_EP=4dS5rQVj+bh@qqkd#^S^>EOhByFNN z>QM4IYPu9=>A^wJuL{YH=NQW06JtjaBNaSFSA3?g6nFAs01&Z&OUN<0$WkcO0!5w1 zNnGjm#khLSX%}r&Qmt!W^xF;VPk*_R6ru$M3J<;_%g|Y`EtfwH5cBWWUEN|??MY}lJC8R9XJw2>3ZB7KM1|V!NA^9?mNpXVoHqtR zihsEq83rFrRfzE&WK`|W^uw!bj9tg& zZSMl`H0huNi>+b?vsICWbXL&ro&H_e%ek7^LKm^_===)CzCXE>aU%_M(f=Hihc}_u zAhbX3(R za&axbwCMQ!V5vuapB@0geY#IwOfvtRw!B;DsPP|$&$G~0m5-0lW_Ki%UOD9{E0&a0 zZJ_)=pWa1_uRErprw@eDh-kUyCbKd?FbFWzGhW_V0NPjS)xRgWH%QbIu({50?&QG= zVm71EG9vej(eV;fC`~(z5C45sLMLpz4S}W-gcZW>N;!VcnB2j^m-o@w%JQ~G9DWtT5P8Dh+r*Gq~&5Ejh8BMU_ zW-MO{Fth(#Puwx9^B33B);ig$(8mf?e8A+D55$kOn%gAAz zR$|K)CkB}=va-s;t!`%|#BZvv-s@2|?BQ*fm#G9Y)0CHC9VR(`!qt`ZvrD11=K z^Qwwuj>pyAdu^Y_L)Ty;FK|>wZ?l*ltE>QdX~Y?$d;1%GP&5M4kL)Ey3xHd|s81=` zylu1Pl}7dZW81+-L_xp=c=@u;TPYC$h0V&qk7n`PYgPg;wOToVJ%G-Y(|(tTc&fBn z=S5%ev>R-*49GYw`b+IAoW-R-n&-3tQ`UCK2AZ1^Ym&r)x|I^4p`ihI%uNb7j+r}i zwec-2td*A^p5}k1TfY0J_QQ_PW6IU`92Ed@$L+S&gbrF(yw=CO8g4n_wuqwV&Poi{ z1fFKRX zknZ(lC%aw70_^+B*2En98xH6@2N6$UC5JTJnL;Es|0iB* zb8|-6h6Mh*Yftx!cG#vpV0jU7@t;bOFFxiddo|=Fo{g@MbH^cEqYL(sj;c>bc^)BNx4)l3tylI z(7xH)9`KXdvgRe1C+(*EgQ@bQ*;7DesOZLPb*%6%h(xUQ!EKIGAzl0PKTjQcr@vR% z94B(g>EWLBC53E)@HqjhP?gQ2Mjxjaa`#&1oX={`c%=I0rYAq-JSRK-25;9oSBTpH zFN!H7+EM53MK>woAvjok&DUStFQ7+7SQH!8*+X}s7BS_K_4&s$wOXf63^_$fJ9H!W zh40xpRfw!&$vmzPtgTrkXNEJR;9l>ZgoQ0b+n3sXL%MnIyFF0 zdsCost1^UAqtA$dCUoBsc|=>-<^tCUfy%Sc$S$xT{9P*QkJ(IqfjWviiY{7~%S!+p|9X7GtSRO7Qgu zCa}HK``QKbbcg?|Z!n4G!gb}q;NSvJhF&1SI_8sr29n!M>0+e5sFkjqG3Z;1Voq+wf690HHVjYQDwKz`>bgNf>wQ{J@~Z zWDtIHGmU*Caun>a#QQkZUY|X#wXjI3frk`@gER4{2q%xB?|ONE-@-Gnlx-~MVdK#n zqlSTj$1_i4t(Jlx*FUXx+o8S%(&VQHiPdKVZRMrn5B@R0qqiMrqJh#j96uS4FHejG ziTUC3(aixSG&YkMb`Vw zTjcNvPA-wLX~|o(0nf1hbsaZ|x^Bah5W4=egVT3!wa8?kX^V}`$ei}K#3LgkV4z>S ze0(xhtL^+)KcM{Erpgya24rebm5z_hNl*jY;J^%P3%n|Cp z?&&Xm_}AUk|iTw ze9|;fK*7Pmxqj}QE}8fM62opvz~~clM=}b9S@;4oa|)Tdo_%vEEIGF5Z%S49T=cnM2i-%Xrw7@IU@^IlnwE#Uiiq2Q@er?c5^7bJuXH`Y2J@oeL zE4MGnDu$TVWX$f3WD}E9^T`{-^UzvaR5Y~nPB(n;$v)dC-rZUMHEL>pk0V@$l+RiC zYd}rF$Ulie&CD#l5+vI-T@K?6rf$bD*-wKEOtdkQadMd*yY3#j-|_J~`Z1`~eD$}q z1m>thaORCRK<)K`^vTv>mL3`vU zSfejC5|O0BBp^1>z&?b6_Y; zPLV%-jt5$adP~;U)`K;Xj4(%7RpbwD4^I9LrU?lRzb*Ju*RNHJPPs6QRQ&$MdxJxS zpK~>io^YPNi+zdJ1}27|``p}~r^+G$fWqm#%R`_%CtR$jcAXI_GgcPJNWf7hXlBL$ zpi>~|C~L0ohsJO%ncM6xw@ay|#7u+_yfF?B5BHBZSc!OS6gG}Jl#1E!jmgPbm<)N53U%1S&0ndV=B2@h|# ztS(Ajx)0s9#!SJNxxhF3pssqw$D&wL?q18+mh~s`w};l0*jl#L-5^_BbbWGQqbI89>F;l! zv|fWisI{~fRz@QN5j-ARMngm{?PM;oX|;|sLm&Ac9Z+%M7ZzqPfeyP$l@(&PJcIr) z9M}lq%e3O|{#I2Lp{nXt0%sN>hgpi$-DjpOb&S+^sKRn`IPB(=-x}uUCI~CBfS?x{ zqEq7@oK$SnTlS^fRTkI%p~4p*ndFXY*msV`P$s%*hm12DAj{}4Er_6kY%Xo8b1R0sg>xj0wr z(35n5@ydSDtq{k*wWGg(%_}s&xpx%NjhMS2*cYAT9<;1pQ?6Mvn_MbvtD@>qMP*G{w3v%}K_Cg?u+!u}IJLWO<8qe6h^8l=` zu6#Su0b1F&SF&g^-vr6FC#!M6oIkZXBiWuS#=W^ZS)jgJA>zep#dyKCIkNibFj-dN zL2&ZdwiR0W!D{(}84a4JG&M>}O501<*Dg6q#W?iJ<=(MrYzeBoOm2%h6tyliM>$P5 zoFqIhUlILrnp0k-jz zv2Pp0pz~Wz@*1aJRjRP0(9iX^NpKt(j4#Uyg`MmBChX^*61tymb6t=9z+zCZ{C0&w zQXr-Xe(Os23-S|^zk`5S*xWgafJJo^1pIB%0a8$8`&w|>tOZ&hO!ApmQ&ZR9aQIGh zDUz$*satWHdQlKCWJ{-P#4GU2D*sJ&5tt@m zxRAqBdRTxruBL*GA=wQfBqYSceoqE=s{(tQ&w;h%VEWr;tIs&s)FBTUW3a3$f;#h;-AB=(T(+B|;QM~I3A*4fWz^D6^ zo)p0A_&}mod44#b(;^v2u3ezRM@ORahS35Hez8IX@=8 zu4?ERB498}is5o*0IHI~g3!ttJMyfoteDGjsKv?tmw~1SYJBvC8iQIo`uf&GC#0Ns zOH|@-+pf;=cF(}s-gTbm-v7NH@0ZsPj^Q|R_{H99&o$RvbCs249Qn1O)WC{lXlRAY` z55m19ngEtkfP0cAa_dd?5$QBb%P1c|*EoF+#mKu(VtVWrmLf9dWIi@-7SKI=+oSh} z(l3IeL4@W&j0FIpg|i}P%=XUu{&cEZ5-n*=PfcN(>5~hs>SQd%;-(5%Yb10bdyPJg zcLhXbBn1=^Z~Wk05)?Vy+QMswF41uctVi$I z+e`54P1|lRu4xFCU&XU7s(pX?%h-(@fLAVd7*?}{Mn}FC7s6-zxn=36Hr^)&!*-$@ zui$da9PH~MB)|i0fTMqez5VQDml9f_ylqWfl5=u8eEo89Ku~%6MDL8<$14pnQZI)9 zCx}ODM8uR^;h&l_2Y^A4EiwLpdW`i7sgjte?q~-89^^S&#w~eEB`_sD-PcsD67jY3 zDB|~T!yvT%CCvf<+13vEDpdgn2F&H>s=~z6LmQfOh?qO51x3a6vyEb-1z=lLH!%@J z!ia`YQzNm~zb2kzQWG`I{yBjn2$~>^`K4zQp7_Y06jS{PC5(mJdPRnC5y3Gx8k3mg?eORE+uqOvp$%!~Nak?Np~w!I%#p zi>6}{ZP&x)u>ufS`u>h$!!<-RSh??A%IKc$@HrjU|F9uP)Ex!CIFZ<+NSYvgC9(8e zwe!eJrP!b)`Va~(${)wI7gh?Z^u%wKZjz|=R*OABXue(%g~0wi>e+)dlUIg@^sb3} z0^H?}bpVHvb?j61CuzpSImRxuigE(67tU8XXEL)`|3DUlg7EaB^2w#56bo$v=(3|y zS+YWe@4gU;Z#<0~G7^zdKx0W`IGEceAz*mGy%Iera$Qp$Msr`Q(Kndzr8VkHzKPwF zHoSwCK9UOFijm(o#6Uv^KH|u39BTG;xvI3w+QHel9dUNZ;y8c-OJA#_gc)st=l&{VX{u9;chiikc$2r4&nT^G`n;~}p;4rMKuTQY*{$RB<3#HDT^$`>}lbP8g z8QEgE9qxdMmj6tG5HlMd5^yV*8*(J@xrK;eQh5=8q`Dp;sQG-lVZ4^&;_~u*Qy}U< zWO;fbzl*1v;5}rZg#Jc_U&j9PG$|~m7sAf%?XN&Ntf{T-DR9k&_U|geV^s-U&RXvk+~>~MXg)ahYHg!TTCbZ7hvvXT-`LiPfI zM=Bq)okQSABQJX=@JD~kYl(k;_pHmvc;XM52cHuq?-~a1jtE5Q_JmMqo#x7Fl$S=! zQKqrJzgQ?K_)uAHlq)z)_Olvxasy8Bu{9ZgLR)|yE-zO==}d!!_pSaUnTkc!0Qhii zF1Up$aM?MYifjs7V_92U@63tZAQf=95pfotHV2uZh^s6AgsK_>(I^(woZ1q~oTQ-v z0?7x@HL4rRt(M6N+|Dm+ovUI|+$S^%G_1(XI5&?OAyl9quLZ&)W_I1sr=p_Po=<;9 z$ZSu`&cQB|_U1v*V}ATqA|mBlElS6uH81%>8g7zR2BrLDrRgjE@#cpnE-t$}eUwOk zG`7@K>pDT4(?ngJ^k{1`l%UI{H%IB+Czs9!I0Sp`>Yl{;^Ll=HKo47YU}YeUI6PcE zB|<|iW+2~BDS!IO^XE8G^s72o6QhJ&TKC((4L1&4U!n-^EvC|ng@`6YKe2wZ%2>KT z>*3FWFgk)~F@7K)PX!CRl?e$}jJ^V`n+QS3xL20QFm!Y%gNR?;LKK*s+n%7KlCx@~ zKl=XT2I6fZj~Ap84I`}>I-dO!sijcLv?XBg z&SCW0d{+B$Ly56=#{)!_>*Xz%Gi<>cS~3?$v{cKe_MHRsmNky`0;6G5DZ-vjnXO4T zM6uzJcTof-g{?@m;bopw`~<8cVG)uRfuhfigM(;_ISN96EI`5G0rD;GO*6pKa9Cp8 z+3+w_%$eIL-&a1}HqnPng2n*RcqPFjqns?lC{weHveJ5=H+%?pk3rPA@IuGd{1fyLz!%u3B$d)%JKIAK07(g-bYoCaQ9ZG~7S8;~^1l}K{Aw`Fskl|RcY~3$0y|XyLX0W=7gnGQz`k{9>-5Td<-W57@yq~CW zA$Fpq11E(H^jDGvT?TZVJ!31f}Q?-X%S>lFmi7c3!&>Q%3jD%fis*` zw>?s*r%q5`KU1ft<`NkXe-~MC7d?ntosLqNQf!-VKkyR^CJaHQ-^v3P#^b+lyMKz- zl^qxyEOhEoCnP2=+B`ejiAQcJkkEIZUde|;)&kJ;-Hy54viCM}#TpHZ=0yA4oPBCx zYxx)cv;8$c7`Tc@mnXiod35*47IH$L=x73M?RO0g6OR$*r+W|lo3!CobtH(AuL_f5 z2RH1kt&;BxJS=qEx;)2uLJ-K~l_a;cw1jMHho0D^yu9F(a~$qP>GuBq8`_10$8+0_ zgCcvi6_w>E$X%JUGtqvQa5nRT(l*&r_cb>UXMKlKKJ)3ZfcfDV$6J-#2+W7i>uYm{ z>IR#LT%6D;Ii6uNT&d+1RAq+~d>xRtTIvK}r)Rft9@P!WzpxY0-rIId0zoLo&UOg! zN?M0wHf4^RkM3?vc180jo+Y;*%pwz2r z9@FV~v(a0kmh#05biRwq=PN{4j<9g2IbQ(Y=7JUl$c_SRC2 z$bTP&Fm-|z*62>ly98$H8{}+yTh5%un-z!1uK`7A0=5pJufxs^`M-iN9yZP1dvCcl zAOij8UqD!KN;!5fJ#+c~=fL{=02bauy8sqkjPH;MTQod;rrsWd;VlJG6|0e13P_eB zerwA8omtAa72z8bMD_l{6y@{U_3OUI7`p1!u3cc5Q>R)+*n;(lHON5IuvvI(v?E=u z%9@Ds-bZG;t*OPh)L1vfzrUkV(5pSvpq_%4= zd1l8OY>}@E&uI^zc*v~o3Gxzby|*JJ+L%@D>}{W6C&!uB0B#||^Y|JNz}M#6rBUXO zd^dPBDY|l&#fJYvc5yb^aH1okqMDG;2rlMS_Km#4yY%#QR^$Wxv}*Z1@;-`CHQ~GG zWYi4nc*M~OVfD(Ryv(nCgEbq0@AW9OGM^hLq6qU}9piIHez}fp%jgb|)iN7=0oc_# za0Y1+eumJ4eB8!kRX*4euGEyG!CJx;VN!;^tAiN=^9ys`x7`j-SdfbmnqYMZ(+}3o zbk`UD{-tULQ2NhpMpKmPK1*s#kPc*5G2r*y79ZgTLY1)9nc?p)&X>*DaPI~hMZbTK zm>akj*;#8%)w=cg3fP}~MP3r9Aq%xY0ih#>`s(vRj78k=m(bcCcH>XoSBby#A2L~C zJEGoVOHk*R`n-hBxuIrAucNzOhegU8XVo1Z0u)(uRU!7jEF-%mh=W>#ZdQ*Rh^z&URJwRi^{vp|}WY8XCXdgFUT}rKi$c zJQrTq>cSKfpMA%qxKc!ps4^I58k@jGO7)LdNOq>tAQjgU_O`CziL z41&ZD7EL&>xh)T38jMKsoWkKpc`ivr9Bhx`9FWf!vWe*Zi&j1-tivFNqN~R@Uq%DCZYPQAoN{4em2A4{RJ zvg~4iwE-K*x+AcAy1PG5#B$Crd{Er`mh$@SBM=wn?P6fCGe4TkqqDuz;L9*xix=>P zX*+wtLh%f&@eaA*hAsO8gum<;r_96a_)n+-B_OBN_Y0cMM8HwL*ylU5H+3k!nlS*V z-G{fav^!6;L7qng|Fqia)j}?abrA~O2=Jnb!MM}$LB`xH-KC|WibrS3I9-dNu5$Rx24cC)2t9f|zw_%*Ly0gVY&QY-qcRIPi_ZRp?ivyu!_RG~ zM;lE*%NEVH(a{+@bhX;&TYmJ_8gVq-2YUs=MrLFP{@%=a)LDR=%00W>t)Yort@G&( zS1I(DG9vhgvl(w=yW#DyW>H0wyp|ER?WBS=g53jw5ZPtL&0Wsw;NZ{+hw%gT2;9_W zW++r`l)ZWN>IM*i;6xQxFQF8da#q&w__~B2A7WsmSSTT^lhgI>RpXK6KZWfBe(gs^ zt0z36W@=>gs^N*z2_bjhT%Ea4d}N(0)_Y>dX&BFIgTy&BDLM=3s-=d5;YCufD?SZW zevt3Z?>k%{d=1Xu58h^M_s(*4&--E%Ck1%j?A9owJ;n^n1>j!STZhQksGSwlIw>IU z&2Nn_v{)EnLFy?)0+(9b+qH4T#M*9c_rIV7OSBX_^B%eB`>sFS{G$rzYEc6oy*Wf7=Zh0u z6K55x1TzDzyFlElR;bz^iwW+PqaN%mduKV`<>25b-y}6Qd0H#;=37a%)3+~1!}+Od zDjEto>GquQ+4(sT9c6b+r@mOB-a1~Q(Gh78k7h$XTB*OCFQ0K2(js!;nV`6};VS*` z`I~4mX#AQz3nn(VwPYO0$ufTqz3cOu1_nsT@LEz5^DrnFiLXG-Sut{P@$A%|Oa8=2 zT6T82=x!C!jeg$Dc&YLm2uaB{M!!gWQxeFAeW%BY7rkwc)-gy(NI;97fY+^*pTL5c zIb3ge(IrYE;Bj`i7S*0#V_*=T_Pc+SI5D-%G5k3o4B*+5%R{*cMLVN~o=Hoym;4b_ z6(ac_7w+k_d)${E{5&IJu(wyx?`Y!e@z>5~PL{0arTF=3#E|{TiI|T5`SIfsH~`R= z3KY2={}OjNk1nzs1#%`SJ^fZ~$Rt&vCylS;0ht&6%xUm0Y_F58=oH4aDq2E}s zk=46Q!Ar-(oh58LB4)%N<5AClHE;CR*!TAoDqKva6IA#gu`_#kRr4{a1~L5R58tv_ zpPz)_sQfWzzJHP7A_ITDbJVJ}yIZuU4$(~@n3%)<3sXZj5oP+DDzV}(FSDHJIQB=6 z9x-!qy|#SwMT)-Zi{o>GrO(U{#NVfgk19(Z8Aqz8!jUUTb-%vWiL2YyNReL2*RS$Z zbd-X@pV8lMY7Y@lxMHCUS`yVpVR50Y<~W zZqlk|&3$Yzu1euI5^;I)Vbj4R;SW}^$$ZD{=h>4Ae@kr`x{W2JrKLevWWJY6f11O6 zu?LJ-U7-x3LSW$`#6N$#;ln1x$H!;Byrp)uUrYGVPc&F6IY0k7kC2D6 z1;08}7r+-Cx_moMoSLPKF(xtI;n*pE6yHny4Sx{kr8r;1eHRxNo~-|)LHMwJ&^0iU zYyfD^GmC`(prE1IuzD~!%tT-HXY+0V_c ze6RicF6LnWo56e~91oOphHgT(>V#!CBhh$HK3 z9gO*ObXL~aDS65ZuHF|1E0*vplq5>Kz7-jC67g}P^O4+RfV2t!d#8Gww>QS|$! z+owS`PEp_?!XxBmN=->PRgdll3Tk;N6lY(kwG|f^lfO6jg6=EBT`x^zV{gbUDA@LI z(ksy7go%51sg%yPhUwU!Z4kkegIp#oHWsr`xznwp;VB+ed7;GTSClg+UHQ-c+&-#g zs@ijOk)-zBDOvkUWEioaxVV%sAZ6o?@~AL;w|tfw(2OJMcEZASJ)KuqiQ-zmM1RV5 z3;_qFwr7?9hQb$a%GNJmo(2D8VdV^WZj%un99H~WD1mptSQeO}VA0X4(6)~Z%@b*o zNXiM4uvd%(jgSVB@jL$o(`RJa5K%Yjv`hH1!2ERd%c3N+#U4HuAvF~fn(dIwbn5-RM6f#o(by02fateAgujHZ*n1)d}=v0OeH zqChJ*jeWfby_|P}K@xuZR%?>7vbq5j*fW3ErJR{oS<^eEB&O&CvJa^=wB_#q)n?%| zp76#d=Gsjijpd?xN{xaD3SMg$y@^;Gh|cf9yN~x^D=_`nue+3~6_y&W=!qY-ySO4| zCOa{aoXFo_;Hl{mdX4=(Hbp$DZzy^kM=t6(B{To&*_A0ob9X&##b6 z{b+%1WPE&9TlFy%VYQSB@bAlP7QG)RjZmzvuyi?28Iu+{QL#nYb9f(hq4?P6@T1W6 zURNwv!5?O&EH@>5#r-nYOD)9!VG83>0^d}z)Exj=QR6lrfXVA!Hn}Eui3=mwP}jTQ z!v>AU3FLFG*Yj?1+?QV-^ks(8G2w5txUC%!4X|;39-m83AdTU0Vgi#dAMg!-!hgko zc5pYE4-L^q@G_yxd87pj@<8Y)ny!=Rig#FlH*7-bXk|UBA&wK-EcU+CGOQw0$)+v4jo6;f#M2jhM953)HTpcb8Z-l8$x}2= zD2k;*OdLdQ;YmuX58xd3E)M% z>j#Gdy=L};0}YKBvbueGdg>2VM1rNI%=dYZkj#8f?kG}!0`wFWc6O(rJsjBuR_C11 zC{3jGq5)YL%*DZO8dJQc-(Yl>{5||8YW;|hE>&Kb(;>Vy=e+feU*z!q6N87mc1MjripfFB)(Co3WMvk& z(RU{z`jO(=CkVYtXkOX?Fh$?Ky{jkq8ER-TyqjkB9EX1o4CH0OO%Pef>z+7`i`5a2 z5d=z~+CAiA3H0tS+OZ}tQM7W~j7a-rXz+t`>XzPbe z&BVYqark zyKQ@x@DB$7)_aHZ{aJO}1(lT0&8F$am6$1qdfi|2!t90+XLXXjw5vdVdpLkLo@Ci! zo^pbtc{mAklw2CHZ|`OKo<6fG`rIv(-AVV7=eblAzq9Nc9V{VP7b@%s_0pr-;zl5~ zk-9Aaq5jAK4@^G&rs4&UbtJ7-c1dCTkYDyeQCrWt%Ant!S~~W9!!N*rYM+Og!`p}? z>yj;fOCsFzm;SM;%iaPVJ3G5f^`m2@)&8%M2lrun@n}IA$RiqB8qZqpx^-@3w71x%>51ugOO8!sop=ddEZ)aoN` z6eZlU`sw7C1o+T3inGJlpPBr8aQY};CA`9B?FseKPdSIFrqSEu4JGsH&M&*;Yu)*) zJy8&%qBdH_pS&Y7-qt6Dqd>PIrcqAw?~EG=ZtjuB`g+pWx3Gne-JdXi{EcsbFaJHNSQeO6EKA1Nlbu=v*dGvKDckF+;@S(&df@vj=XLatak}7g~}}#6)I$XvK6u-zJcHYScbmv6)ilQ|OW06NTGDp>!uRfM&P*EkxQp&%5?6zuWP&$f) z8>2Qn5; zvEtDm5_ny1Jy)mFTuXFI^6Pw$LbSc8H&2ITZ(&(jz_FljWFVxZ^i6GQWp*~i1^w<^ zUusL4({oZvF)W?LV-EiHYfmIj2Y54wKHE3nf}lFZkjZmXQgC>_`I1R-V0{;v1@_KT z>Q81rrEc#mQyBQg@LEHIj7xXx<@3e)e^^@MjTRuRDVa{i(xVYW>O}qh#lg(G!K?J3 z0u*IOWG^wGAPQP5v~kba__lt!)W*A=u35g=Qc0k{g0Wvb|A8 zZB07Gu_MfvLl)36TuI`iqeI?QZKA$uEZy?j4)Tw@7-3x}D<|s%8RZOc9QL5ZTN}=4 zq07HmuYaDO{qtFf@ppzi*Gzhsla%()20`Ri&U6}PPYF6BqmFQ3i}Vf+KSakNZ2+GS zbC|niJWk*WOjKj{%g8_suVa*T4X*hF5sK=uI82b!HLVfOn=*h4>lYKqHI!DzTIqwWb`&ADf!1k5z+Qazc}3l@!({j$)5-gY0^JVIxAs7nKps&y z*%j5bA0quj5Sk~Tra;cdhR4tEWPUabfzKn zt>3CX=hh0VJbpq$gT95P@%U$l?TJlqn|uaOC`w&N4jrk0tLHzx?wwR+-Hxaxm8VDE zH{Gu;&t3s10O~3A4Z&Rc!=KjX+>6ibDhc4XkBpCMZ`Ziod66s!aK}sH@{HSjhq>~B z&%l+JA2)nwsg2P~hckt(A8HEfllA+$wtzIT>Zc*rdSh`bv8r1@U*<%{}V+*L&^`>GsL42S0xNfU16M15y5T`0qp^ z3tiz+qei8%VWH9ElshzH5-%RsuzCKobpbL4#5KBd*+ZYjTK;N-vL-Mt>z4cEZ+)?o zw{K5iT+depW~)l>JeA^xh>Uj@OB)~Fo_M_W3j=M|k8^vg1|r{jARxH4kXF1I??yN1 z!&251i>W&Q{ft%S{YVUppGlRT*eQ&+u^rjFHNGv+oHF6%hMpN8VvIrp! zp4$-k5g#91eSVY*MWdJ>cMzFFwG7!+IpHmevLP*1AKrSRrc5Q zXucU4*ccK-q+h(7eWES9VZ4dli|{S4Va^MbyW;HMy~C*j3Tb6T8NiQA)b`ik);2b? zz%774%i>lf8J}ZNj#9qG*`sGFCo|oXm2t`hs-5JB$Z^xrxdO+3yAgv)%3OH0~4Q!dQR%vOVbj7^Mtt5uYcl1RV{o?c#jNKL{s;}`8~YnGqeBh!H})j}$@?5spU z(mmv7>RD1kAd2;^)1QN#T@bQ*5M$zWcZa}C2Hsz))n62ihAcRlo(BXL;qji@{Dp2U zdkC8VTOl$Ad;b-NR&feeipLM=BU@mdFdydE&TcIDcM)^#ypuNsa<0C4?9uv=nIIh9 znX;+8w+_|=Gc)m!MNKFkLi3i@C%xsFwtpGm&;M%0u}Ko;Cxp74TDn@D_UkKQ^jaqU zf9V`pSXjXDdtyGE>#fxwv)!&uiEQS>qMzRC7evnY7JenrOdXSw45Y#A0dC9 z(yJ1)eao4^?{rH<(z?dm!vh6M=t!YRqtpmSPXeFVN_rRQoyp=~*xW#jjEx|{8W7w< z;Nl82k#FWa4Y*avPK`o~*R7eIFiJx9Pu!6E2RSx3i@%i+a=vh}6)l9+9thphDH z1#>{XS$QG-g!*t@=ctn?=HQ%c$7Gyzs;f%VkltOHQ52?#m`+s$2NSM9V-6 zWG%*PT#)nuhj z6Y%L@!56nQ+1X%tx_zJxI9g;GJSaGrxk}T?UFhZ+vhXeS;{yF?a}*62G9)x!FWDTy zt-(98wY9&RLyKn8fF94o-PMS-BA-YL(70m}ao#Zf#e^g!SJI!s%htZ`Nx9%Sbc19L zgXD!Ba^#{+hAqj@jEwoC_!r2>t|KaW^zeo21uCcu=}O5;8C_glf(ZHXF&{n?0&K^A zyz5)>gBSGgs1UZxiMyPo+g)#sjg1vBbG1x?5eIx*Nf0Q~-e?fl^w->NuX(75gy}*a zC>WDvw*j^Kh1#(JbkNolraEYkn#cN++5b5oXVM>9kCWYM`8W1YKbGjJ{ zL7BPOpqt@-gYCxGaTWQnscOeL)`B6;<0n|DA&U*T(~g{4KhiPT;IYw0&oj<&{eXfmjcD{Ej}R=KSj z2-?GAtDt+2M4k28Zu4Vtf&HvAQVs!|)UT)rkFm2$R-%>y2_&jd>^buoLDHxEfung5 zj*Z1Z1=E0HU=!M-82kWWu~^#A2Ce2B42VnA{uL;o;XOYkXIQ5!F~)ukT~yp$i1ndj zOi)+`xZXr3r+tE0>{utaJXWuAynIxo*Zq};5(dipL%VT_`ibnOfH?-NKa9CSAGs1v zPGGDXxA>e`U=t%>C7;)gLUL=$TX#vnEb&jZf-E#-2cvv1`_0%Ku##G9*H?85bZ$v% zP6B_7h9Pyn1@i7@A506v-rDC?u?{FQHDuXu$kZ#$4$5}%W`_%JTJG=lrbQ(7L-_9- z8hQmfZRGRi<<%{4+U5t)fd4P!vgwqiGrRDqcd%3;pEC`RN-`0p8K)^tl7)KP{79|I z$i2Z*-&0j&q9vD}C@pRv%dL4wJo>@LI9>oyofPzIsGO!_o*Ns+E9*+$QBe<;AAVc? zm;S1^fV^ZIqa{p77AenHRf}VBT)-ft8&NK22Qw>tki}`%r!=*8b$KbyH^*>mJ=PO6 zZ2J8)hQ*2iT0?Gvj=ecLTDis=x5Q_&4vJ&C-S4YL+4SnNEp)uMgX-qeI>_$_z#cP! zu2!f!g@1U1y*BIzd0g%tUV`kI9_j@@fRsUGH?Q3jVrtECmwMu{p>hIR4I+qoO?{cP zkGbtJ0J7+h)S<>-Tr4Euv?l(`*KtzFSH~tNeucE!ay~i1_4W0IB93sQFZM$JKH1GQ ztGjdtaq)TBB_^ZZfPET<^0Zppx$?v$$Y_Bc zLEp$ISut=ahTY)C3*r>VL&FohQ`Sz6GQukqAt)L6Wca-(F&p>%bv03615~os@vnI0 zGX2{jkAq)9u>qRDdM7f>KR5boy@qMBo|8^T+R)2(usxj7iXt!gJ0nc+NgZH$i{1XG zfG~j`P5n5SZEjwmcl<&jEg??8jPvSbS^=4_f@-PJ)4{42I5p%(-JJ5#Wq7X*L$mC^ z{kT(Yxu|Jkg2q+aK=h7(s;j{AR1P|&?t@)`7raPli4K4+Ul#pV=I)IqpcgdHOP(oL zS@YZ_sBa~`V+XCt$JXe|yDcJvJis`GyW z^E&kFgU=}@!7T8mBe=yzw$72xdSTVm=Y_wyzb{k5voWIxwj0CUT zBCCIWX*hN3;AdhAjx;W#KMj2Mh~Icc6Ji(^5+{KN9Z)DNcHN8@LnYYD<=F?@U+A~n z7c4$pwEX^QkwCgd$l^@f&|o`rEN~@^hwcOu+k|F|RZBkMe(p~20x`1?z+Y&dlO*U8 zg9ge^y1H%Eq>cD4myb6#k>WFXz3vn`GomWLO5!8Yt-sMG;}&ZN2GEjo6T&$%zCpU> z;ZAy`Kl;0TqC7tSu?1+{>?^GHS)YvV_X6W1+w!h%v`v;tJv(zJoYpZRNee~?ehI(;`{wRB^{nCMIw1@q_;lbFS|nL3Ux4$ZvHQ z@g{e=)(%orWCs=G%%`%sb?&4w7w2S1S{D!)v%{P{p+dGd30lV?%ysnu_W=MSI!*10 zahtCcB~;?N<9Sm=gV&Uaf0HIB$BwWr&u#qeeu5sk?U&u}cWk&8AWP||vp&Qub#{rv z&1s=|Y_0eiE14Dwts{jdm`D(K)*FnZy$TCu!=??uvLp0+(Q^A_H7TOA%Ns-{k3X{t zWYeXVRjH^}lVd9t2S>U5qWG;__|uh>JWV9{wBB+6S}$Yx>`31>KUT80D{>x-7 zBeROTS2SLv_nenux8of2{46G8?5;a*>Ge3%4F6jTa9vk#f3kzCotkA@SZ0hE{xDPy)`@4=I!WWKINK4YtD@Fm8%WBs~QNS+m;K6 zT6bu73L%-rEpwTte-ZeO2>3vu*lAwP%{8}CVvX$fQWGbI?4P0F=(LyG`tH^D3!C$9 z66>wC&=1#AO5Cl2Z#bbf$UlHDK%RxB=Is7yq6pYFlXpMV+1|o%9k3G`<+nFD+s`+^ z7$*%mdBGLPm@fyp8uoMw*;#Qglmwt;&h57J^9|PkLwnCq%hR8I|(44P= zc0r6mfixrFk^$`qQhxt{NHZDyQ4F9{^jmlQsjT<1(bBfJw=ie~l2w$>9=qCtln}WJ zfW=b0YH$L1)IhdkU=d9QJu(mhZfoq6Y7M0j2zcJ6A=2oQ*?Rl!r~2Ojm+F>k zs0Fermse(8-+=<=JrX=jd5@&ONF36?_v|cLLmo&HB<;);|01eglaBnSOqk>zbZ39< z|MOW514h01Q{O!T)0;bc`y$A7fb6Vshq5m*wR6_E-PR1Sld4sOe(|`K%+4$K2p>gn z8QmL%_e^|jIz59PK{f$W3uNV6H2nr4A>k7~%A1fZ%>yeC!(C%OEv9R;x1MSKA(?{z znM+@877ZMzfp@^c7}`QcsvH;~%=ED_e@%?LaDVf{4R;V)B0XmteKkkFxXq4NM9H1s zyv~O1R_1xKw)Z&wu~!#{(4By$rly8L{JjCpH$m%*FWBogM=Wx@Y zjz``!@U6M(m3JZ2d~Vxg_NsL``9H=D)846GH<2AVCgXO{oP-TzDKa8iTwIS=Zf9nO zaAM8iNPjs%6B3e^y(j#XAXoODhd8cIXtiWeGl6t*5{ug9Mzs!OpylsLk9D24--`vm z?`zG`%r91!-Wrwg}YF$n!Mr2eK}*3G@iO0vp5x zKBrsq^73Wn#r1!0&&$@Wk%e6pSrCJYOo*L?GYd|F=N1RCD{VKM zSO4iP><^A+-G0xc@ljvN+GZ>oU0TEM{ZAT4j~cqp_Yt=R8K&Z=mFEl)v2S_>2ZGArmdIk>CK?Sm!FzJ!PP4cfv-|NBqMUbE6( z4uXF{Qdog+_XIh1O_&2a-y-0|+aRq7r~?ArYFzH4tcrO;Y6^;!HQ(Xq3im^B9pxk0 zhfci>p5g^CT5OJSARV$1fByVIedcd?cQn2oWEnzK_iq~pWIz1xHSo@GaA$c9mnlSK zS-t$!@F%>ytZZhfE0){o=yAeJ2Z~(kY>V8yJOG|EbNvd+A_{ps|GAh!wEX(4KXBVsE z+W(I~WbK1iq}KyaHqVI zu$)xP{r~w$f<^1i|9nK%^#Ah@ag3$-U>4}l-q2#fRiun-`R{N3DIpaj7yR(?V@5`b z6hq(rnEyV0cAx*pMbQ6%AaYQ{&XjWg)Jn_ZJJIkj>=x>`4ReotTtxrZy_r!g31`wX zlzJO9?=R~w%~3Ei6+v6g?LfoFM;{Jh2%G=FABCS8>Ew&6^3Yc8gVx#ErDgazq@sk= z^PP-h{YV&Ai|0~7K60I8a(4#ED84tnEstR4a0WNckLL z^o-X^Z}Lsy{sn|0X>-UYZUS~cm;VHVHO;r)+1YQV4-8JfMEM8tdb-!)Sj^6eXxWti zfF6V!jtBSzN-utF6op%_ZG`aD?|V1VXZW9Rx;BcAlk6gM!-b?b{2yU!ML|JCKeGGRel+ZVtrc@NeJz;JGo|_X>h=17sv$^2o=*wj7frzQ5Lx{&5FDN@f-o zAv`hkgRMzS(yguhM@HiM%G@7IO?@0~*4sPo+`02L{<}}^2BS~3qL}XAx3U{?bSh@`bkB37;6x_VrNqRkk zU>y2(q)Ie1sOHy~7AfT8$X3pe#S6=l7~X&*GVF8LFm;1^?~hERjc5J+pz^V>;0B@aeseA zG4$p-uxahHj#lY1{3tI|Fl;!mIX9jWyZM!opyPU~Mh6{^`zY6iAF;7**T=EQK@=-% zvrLS)5Mb;~WVbzqhJcGsu~~1JuWy_r0Y4An@sc$%eHZ7dzCT%P>NTB0U?G;0n#$tR z{4nOUO9^A6@AXJk!@}aCR(*qra@mYtns*iynO@;+c!3kntM%;EvXQ9f^}lx!M}EFS zVq)kSJ#ik&aW}$RM=S(&!89W#sPLqwUiO!<`U(eDWo+XOm5=UiN7%l~6Dzi=dC%|};%E7SI zW*U3~bwA#il~y#kEkCQ~f#d9q`fT7|g^eWY$$7{1)_5tGXviZkpwu-_f2ZAf)DMoxtvRIVNND0FNImL@M62VI#*1y_A9bXPg$UPP-7K(K=M26& z2@NQBU+zi73`9w4rs8_kwAvQ2RSb-DFnN! zK5q|XQNpt^2PKld?cu%mloWGM42L;i6@BiycTKCVEKRSV-@p!KI!8yx25rQml~NKn z*j4Dyt+=bWUC`yyQ=N^l7*8HCY)w{U0x!&NW80i5OSzkn!+-!GR?*2?>~|pMo52IR z02)0Ibt!1IGndCN=v)Vlayt3t@rj88VV};m4;qR4hk{ER46-o|kVt&%9u-wAr^3gl z<}D_yxj%I8WsnmFCZ^Si?RJQTP7D62Sy&r5os?*l>cEseaq%~vQR1iq(_7G#hiYxZ zU~Nx8$MNiesA#cI(~0fXF}Kqaqp`7xrvAy=uTEgR!V(ih^J^|%C37HM%nkY^iEZ};hCqXw!tMQDG)to74~12gCW(b+glyN%{F2v%5_6wrli88_<{P6B(JYb9D4dTl>bV#)d$t zm}uK6FhIN$Tk1WN=yok+YDx;X)LZC+E)~bDKBWh>`=&JxBa68OgP-aAA|OMb*__0N zb>&`D<8e{xoxs=O?x)9vMaqr=as!3zacCO@OCS%gS*=X@mPhjf-@$|AISS%qrNmYl zFNc9=o(d;QrlVDPW}!^q{>g`Cwvpk|=hm}m;tAdF4|-~^H8;l&!32dqq1GN{97ONX zhl)jduy*Uc#4?TG@AtdCT9oFR)1(up>r7Ar2@u=ejp5=VRG)mw@RC#Hu()Dib3G-K z_wy3?u&^^?`P~(vIJ$EA^yyQQkc(3ri~Ou;O73&J&65W$A@Ow{14p9J)a7J#(G zD=UkRKbG?WIH$9L4kx&x#HP@yh0`JS%X`cGZ$i-4fh=#FT39mQC`Jpth#pq+2wz!S z6DsvS?6uvxz!rF}j2I?(wqw!Dy|A!E&dPdUh|JA1(e;8osL(BG*IsJ+DI% zZU-y404;{}9J1(LsQ0(su4nUKxFuJX;;eCHhL)jQDA!uxg-1jbS~gjxl`MBR&HQa+ zTN$r>$S4vGlL2pl+qCD&iM=z|wMtsat9q;2rrB0;td(i9kdV7&AP0wW7`Jie=9t~4 zRJE*v#otML#TV^G$iW8<~u5sWms(L~~Z{JXT`(O@>%NRTGEeHgZM=)!!clP(2 z0B-t;5?Fb7Ru|&Zn;p||`>+I;L?6_wd zCQzV$W74P}Q)G~|@qE_SGT&T3GD6tb5~4FXHi$@yq{>+t$(9^c?*01`X6?XiUY3co z`FCaPHU#fGCc!r|wFMP^00ZU9wcyX6I=Aa)`a=VQ?ss2c>T@VbrE4QPKmX3|uIRkz z_KnPwk<7LA-!i57fhlV-SwOSx?0D&q6p({8t>!0vf4=i09T1}x6c^V!ZC7XPVAm;W z^t^Hv;s>K)6p+R~hI2Db@*CxI8XQWoyXa3O+~0mbBl@LQ8&XHgH#6|_HVlJiu^F>g z4iIK$mf=@_Z~YjFL$leWmxK-HIGjw?NiQy)3oQ?Sypn8Gt8^tnt_C|(=ICeg-OGrz zMYpSqc^t+MRrt?7{;BhyZR{QRU~}C>L#5g9mkxQEu2-w*r)K>yxC5Rz|I13pB(rVx zdx7!=$ll;GhnwT2K6Q07gEs|2BS`1}NkoKwi}e$_DIoVI)39Ov`pbJQn0KyBkKF*S z^1Dqzt8We$ZQuxk&c>7VEww+!Sr?SzvL79JVbppEC}*{s_R{t;METdIaTG&dD;j_uJh5>_r7W>W8)*B-zX0`jAQ(uD75bTB*riD6JH_gZx)vNIR zC{iQudx?dLQgU%UggFTacD(=Yi@!`fr?w0j#E zfE9xeCM_*(-NXzJeZ(N5z~LXkOXm{|hUn*BDzQU*djwsvI!n2uBb9b!XNT)KI|089 zhN_5!{qhh=z4pDr#)D~wjQZN7ZhIE@eyLY~VbN)+pYD0+a(*D|fOT>D%?BJ@MO&k# z3rc=b<}w|gN?0UZS6Dq9%2&hApq5_H{ox;+UBiTFbN(@%y+sPKCtNG#7edn8)q$Sz zHjyrpxy$|vtI}>WGI8DU2urv}y&9LuMmT|)&tVP!mwKgdp^m7|MoiyckL^k?DISfq z@9TM`%j|&v91DtiKRS`e!Vyq0C$~^Fo3aPFA3G4<6gU%Ce@O5sq;$#^lSw*0Ohaa8 z?X}$60=PR(hW2E*`N9+n+CT4)4p!x*Vs_|)qz!d;Q+)P%^q{U>?@@g`!V&KKAXqW=FtyHoVQ|Xr>QYJl zVd7VJsEwo8Usm4u0-O`fcmg@yF=<0XBkn;PYL)Zp{Zz4_dz?{$01-Df7!NiXB+OeO z45peTPTdP^0&k=r4K(y`bq~H*JmNIQK!`hDC}&k=Ny2!&eVQzb!F(9m{(ywf z77OwnFt3F^!}N>V_x);HztsDrrj;T+Jv~|T4$C#`DNd}9z1%_t;pp=Q4-oCu@TNEU zV`5@tUa#ky!J|xmt&-E^BjwXYx~=Kj{GH&ZM!iWW?hS>{utBK>^;ZI@ZwtZ1ud0ik zS#cPa)D~ehNH?V$pr$+`1erl{W&XmUIv$=5OeP==6AO95#wOoeVZEQ;_hLIGDG5=_ zsI2^nV)bv9y#4xSX=HdBVrG65je&tdn1m-=K^-?+9#+?ua;8!~X^9!8P;HG1ynIxU z57h%XYO&bC(!6pNBHl+Nq`*9g#~hSzodOZZk46 zDv%z-c)Lg@EF0iP2S$nBg(tRlbSLnkBUb*YSjsZ|gBjDxA0$kfS`{+>yL@gTL*yEV zQ#|jG{=bR`4ULowx*tA_f~5gIMD{z+B;PAsE$8`S2TDgTEmZwoppy@~WF_wS#^~{V zG)KCr2UKT3#{$HYJG0}ov3-^IvDkcZE$0fI$|*xCo;6D@JWUXdPs>nK(|u-?jRpKm z7C@N!pfgW`Pj0dEy8B#+L0hL5^gGk__TkUywrEAi10 zI9Gkz^gZjB$jTIYEpda6{|Xn;_9PFQF!Y+rowm)4v2WMssgyn;$auoVrIdih(AuM9 zXrz-@l}`7B2Z0hUmKnIWBKr~&#F>9;hkvD|CGiLhynz@AZ_-Y)m|dB{kY|(kdEYK2 zEj_dH#KA;O6zW6`4X?6t*`cuDZ)-A%K-BNo_|$mtk5v@Q8t#La31~3pE}49WsZpq6 zBOg446;$HuJpFmUkr*``i z>eWonN`Hr#(^R9&(PAgwsd@Zjv4n^i_^{`p2e7gK_s;cl z#o-#oi~Ygi`_g|s;2_TA{b&95XS#aP`T03A&X&Oo<7PMYPVM{uO1tiOD*L~Gq>@cC zIx=oMTQETm_5FN4@6UVar{v_&6ILdojAuV|A1Wz{9@?L@*(3-;_d3RL0mtF) z{*a|NncE=r@!z}qh6O&SKNU8fgV8%`yMN$0jX~xS4Mh_ZzvU(T!U9&wpDY)u?so#X z=o|9FXU?GWe^R|yttBMkDUZMFEu)8#Fg!8QxH#bZ@bDw2V%lrxQ^m!4dc^j%OY&Un z`{I3eR@CTtGprD3jDeJ#T`k z-8-`Y6-PzRJd|ffiXyE)_U`PGc~I&m zaWZPPHYz%r{Sq>LfvZ1VQ}C9KRiF~YX+Idic1#hU;CIPl-3EFPJ4!BYMereq4uM_x zkfU^U2mf9De7GsY@OU6C?Gj5L3m>QFjt8^G*1l0o?zHKofH`706VKOlE|nd#&!`W3r+ zkuqkn??YL#1U+rmI9DuoHqptAwQ4V;@Kn+@<$=LLt+kidO83Zg_RYAsuXSG_F?4&Q z;fR?~(_}V_Rw5TV*X%`7>-3Fo?ww|Eu|>eRk`Uf|1=i_-5}v6K*^PIsO#$7Rz8Rnp zx;I7DxV3G&;bX@&OcXq8;4`nA;yy3`T}Af`eUa@ON!X%cyj(25eJi6Ybe_9tXw1wu z^Pta#7wim2D<(9^vfVk1EM_c$!NE5=T!xO8;YsXSOignHu}XbZ=tMK6VR~6uObnIN znmW;(FPuXk?UkQMX9FZ84yw*N1i!}E^K{mmQt5(H%5LDe)Zs#qhg08D`;M?dj zt#%#IT72Yd+}2R^vDk3pBi9M~2r!^>?jr+-DEg8=uxQ_b!4q@H;e|=Ca6=U-dXY#Z zfD(Cea*n7_@+|w0Pp=vAc>JAPO9|xdfY^{Vjbb%k<;4y)+zD0H+0g^m#f6JW!a;*I zVW-qW_m_sTS$Rnj*|BBy@o3sdGReSF9RPF+tWQv&7ppAh-)p8~oIWbyU^>5l?Tm3- zVNtV%Wzc3#3Dz0>$l%R-2hWm_Nw$dBV*oSQX-x1XJ%vg1f4j8t0zlzh_})ZPJ!qdp zNT?#fZdm7cQ{V6&8lH!40hE;Y0)v8qJ)^xa@l*RcScrbPujug6=<>wAi2<9`byP|X z>v5bMlf!*k7DBYU=JMxMouZdgbhj5vb9##CSY-#FLvInbt~rB3QOCu7g!F~5_&TvG zY|Ec(Vbv`YI@YAWDBl2dC!nn}f<;YWpZP262f4Wd)O>8hJ!9sPCH*dYgUwgQF>j&H z&;R{220YLBXQf{ma1>l!mQhw_9pS7aOa6Luk{pLZ9K98qM3cv-iEdbsg^Q z9djn_(rL#qwDjj{ueB$f7EKfP_GV03KToNk5OsIPA_nGHA>4)xETj^8ZEL%F8bD$T z)W_I_gsZN0uW*Yqo2{e9eSf9BY<~ffI4W~#?cwO*{a^L`{QQ&z9+gOS&tDYk4zv0P z^31E#H!N+IIwavIKor&Pgf;m5ciO&k?n{S>DUyavZ0PdMxQQGT zRG#wb4lUi@nSETh*rXO>c;hoGy7c+87VxGmEJhHRBbNv!zUIQ{VP$nzeU_@Tn$`2b)v~YNAubb_kwf&)sG}8E4{vi7Hm9L^yK?P z#nemq$AFgO3>Vj| z>cfl|?^k3M-zXqv+6Qj}Wyo905fIz~SzffsPfvwT)1lDK!%WzS3wlo{n=l?8i4QU) z0WqPOoU|9co1NzEHiV`)IQaP5g=d<(Ef>zO{-LrcfTG}3lT+~eT(lXPBve;dqp8!T ziNLs4QJ_;2UCt|eUp3P?wYn-LF3GYZY1iKv*ao?eZT5`#6P;*X{$u49GQdws@h5UlPQ_CCxe=BJj`j{4_-4csXOgFHwQd~)^!%)JDV4)E6@IwBS0%9 zNey@OrN5BkTsvZ_imUJ1CK;sNA;(`|EU?*64x;1P1b zWTfCZo>1%Q!Sr=C8JaI_$7Y}O>ewEKT%ZW#fMGBgyl}V4oy9>`I!+Z1NUe3)HGfpt z%2F6lyK^KgC oK-{$8T}!U8R2%amRjV(Sq9)Zbl%>%#{6@~`#SblL*IcfpQDpg zWKfH*r~t<1)4h>lA|+tQ9UyF*`VC3#{@lY1J~tWQU(?3;8s*4QPoI)e@S<)8tJAaO;sWiUdul-sNMt&E?Jjuzyx5{<8|Wl-^32;762$!=Ukr>DiJT)hz;{w9_y-4rzMd8= zVG<|rQBhE+Me<~n0wzq@-(*Rzu!@tJ33qgL3}fK>j9tzi=8$(zxN0jaE4#n;3x{9} zUk=w;maJ@vO_?uPYpSdJCw3@bIuMvA&Nq~tf9A$FnVmF&@4xb)!mGk(MlcJeXUECL zJtVAo|LB$MprMgodN8VMtKZ`M6FSZ z@?;$F4(}&$$%Q15r@Aw-;c9H;_rJ!Y#Xk~W@O$0On z*@7gi<6_i8wx`-3%(o_I#6vww7q?CTC=O{le~jHl!L4TZohP-B={eW0uQg@IQUX+M zYJ4R{IYn_Q+YTn^gV1IqlKIHKxtD-ozUP5HmnODz8c4mni}S}ItW6oM;2OqFaR6_8 z%gCQh5JXPd=g=4y6(u-6Zj*0%?Axe)j0N7&|1&Yg)YX>6c$jy+8AtuZ&iwGAC?Gn; zLoX1mN;EzCS?Divt5DRL#3Vh?aYnVv)BJonEn)K(1-yuhtUqckY;Jg;LuO&dyLSt= zl+5EEgv4Sylk?%ps>CI}NH4-k2gp;+dvCQgr!Ne70k@4*>A8Fx!-{YV3}l40%r%XS z=x~iqk?pL9k!N|kJuM45Zn;fD-t7F({vD6dq*@}VTt_PwW~C9(vM3O?@i$?!5Bdk{Bt3R} zs;ZYp#zv94@(e#;1_RKfIaa$2(qdTn_(6OT=h?F+h-i?OmNxTo7V)anF<}>%DgEM9 znE(M|)iZD;aG(nP$O%%A@TMj;qh2FscJ>%9Fx`ck2ZS(o1z}`md+gr~fT0eCq*gvr z3b;~LO;uD`Vp(No3B~UP{Jimk8xM~Z*gUX-v|S4l1dNP~`b*5k5;zs>+XShRKYr5h zKU4zvrFn|oyU%zN7->$ju^{)*RNnG68^iBxO5~!!^#3DqV>qz*)?zD^b!VmI-*lFX yW|`6jXpi?FZvFRfr~Pk9DF1)n&Hw322aJ2CbwVst^S_|r%Ru+CPPvw2_`d)!B*yOm diff --git a/activity_browser/docs/wiki/assets/global_sensitivity_analysis_results.jpg b/activity_browser/docs/wiki/assets/global_sensitivity_analysis_results.jpg deleted file mode 100644 index 47aeae0cfd9bc191ca1a5fe10e4b2eb12e31208a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 385693 zcmeFZcT|(z7bY47L8;Q46s3tYMT$s?iZl@s5D-EUP$0$-=>Y;!k=_IZ1O!w-KuV;9 z4w0@PARR&p2~B!J4J3rgH}|f&Ykq6iy)*Z(duM%baC^w*=?5eIe&+0%GmMO9SeTia&a$zvv9YqSva)k< zpJ(Ub;$USx&wrkahnJ6!kBw76ke^qOo0pIGpD#JZK>rNmnR92(oa1F@W#|20K2ClB zxXv;-GNv+|x&%1Qb&7%O)JX?G8~`|VhThwMI{begr%u!R$i#g191AP`0`z&n=~E00 zrx_Xk={5c85c+ulBi9-3i`Q;5@t8X^U-IHrd7bq6tmK`FRz8bCqSSR4?>FaI_yq)o zgr#L-EJtukHg2?CMJ;=f@rHYp({Uh3cmF#~`us8p&B>QiI z{a85VLpMaf?{Cdv)ox`Bc^vOw zOEDuGnv&D;1MxoKo6XsU;huYn;$5L)arzC5HsDwUl2$Uqu9u%l$Qt&GM~yF+4Px=fVN6s*7Yn>{?bMp0enO+u9=5*b)BT4dw9ob6uO) zG6hI{8U-WSCxCX^vwN!?CLqO-jxAJa>FS>$&io z?kggupLmw;{**$vBV<^6UfGxI+0D1q_G~t8ld41eF{h{?5+k_VI?Je5?@W%7txWRW7@Y;*TibH3 zv%s%8&`<+(!^Cq}llNRa2tR~%W!H`gU@ zhJUGepEaP!Mr!3XFnlLpLCGSX)$CTwPp`7c$$~B2K@7}{>zaLt-r>xCFQ*Au5O#o$Q}@M9c6C2|X&NUmZ%7im@vv9bTf*?%u1v>l ze6}&a3G3dwer2ooSFcJr=QzC&+h%A*T^qx^P>w(f(9XfPKx7yx0oU&Bp+49bKv4VR z)|7tFQsT6wtLpQi66f@A$&dR5vOn{&rDAIl@%L-Tw<8nH~Kr%3SLIgebciZHKj2Dufs4xJ)_$- zSO@7|a={v;5gNP#`K!O*l5RuAEudD)yRq+WK9GU?$z-4T#F&QVc2n_=TnMQ@QpC6j ztnzsJ$Kbd3)=#z#isqGay%O|J-&kro0T3cL9Ec|XyFa>@$&j`a0N)7!&rWg=gZ|EJ zsTYq_Qj;3=e~{EDh*iGgQ}-TQ+MVpR{n1@cS`4}i8&$-8*{ble@1J^}o)@g_B>8t$ zekWi)fXa(}NMO?Bl~JGBo)Vt5d0!~;hX=LGc`;c8jI ziut7dgPjQPn9jWTRHj8w`=UeRzvocKMRw)SY7b!tyt@b#a%bW)>K9~-ET_>ifrK2? zoBD{KmLz!)DIlaf_R!!Wr3MM?-SHViVul$C`Hq03lKlj-A4O{dEPy%RWY8jp9xa4G zH0}Bcb0(Kw86E$IRRataUvJ$FS@rr>la4FPq->V0Y6hdFP3U9%CU?f&XQ&9pPZZzC zCR!2#I&8IB@yMu~ZYVg4-@{M0oG^gZOh7pG!Um6ybi|Er8 z5*9k(c!`9@ysh4f&J5|V_42NH_YFJ48u(1=%C~93w~EQP7l^ye zLdE`#nG&=Am7g@k#o1BrtsSjxH|(4XF1Nud;l^Rv8M`{X=C{uLZ{GOOS{IAKwf=FP z`~=yk@2eVeLdOtN@SJ$ZI_;#I ziM`hs;MlC-q*7_-yV;aW<=M{LmmrrBPy&d1(7l-12Qx2IFW>vW!~zQZ8QyRxFn!21Yxd)8Y~yl8RnnAP0Q6&tbTE z6mvX4_kryg7P!gnT)jWkYk7aM3ab_l8fL?UH&}i7>WFw=IYzHHH@)r?yW7pq!e!eJ zZgNH<ks*eq&KPT@JB&u=jyH0kD%L;I6i7?05VOn%<^sUbq7*?U{$k^6N}a@5dyRzCLa1S2Ml zK2?C|j;Bi$yt36KJ?F#@x7){S*O&V`T$=4S7SP1@3Cl_9#JjIKRbkpt+^azm1tnA! zqJK%zs`8TK%Hb@b(JOb60c!M79_~E>Ozl(4cAk0{np$rB#1NTq z^T=f2m@c6M*T^_yiDr&>_x3RXNT@n3uul+YG3;A6k)OV0mu5$m9>(##GkA6LomW`WgW*t4IIBJ}{NU~G34jsq-QG6-+i^>; zW(Yc8S?3y;A@S&J$A;slq2wyO;>^^S%x;TMz$~BOZlOWL7M8vr*Rc{LZwsKjx@GmA9U zg%oHxPa8=SyIw;ah*fjoZ1P`hZ}5FvPMHPUj9 zsx_vqTu6310sL?jY#}b?=2rYYxT({W=0B;aYcI_rW2Swo&#sDWIhPG=JrLtY6a8O9 z@t{}MP!>@o#)|jc=hy)bk)KwzSibdQgFzwX!GW4ij%$Wxwbu)Ro@de=O)!_I%(T!2 z`@jpKntD<|;sd8i9}D=zW9*tuTi_3AS4Hj8Q%7%+l!ftz6F{qtt|Z)&0BcVS%Pf4W zZIVyUdyv@S-6}nn7|b`~_48tM+>5~qmLDp~g-LZ@eWCI62~b9KqF$72#Sx=CfW)XX zSZ&~O2ImA|1|#l1wK(skco6h)OqO~b)t0ypf{N4l62p*{&~m$NR?c-+vrBRlfT7Y* z$5C&-+fP*PB*4p_DLqflH8eLxeDrMjOOxu-{vNaXW&U6XLe4!KIwPDtupUWqLEJq4 zqDocFA z+kkd#qzCt$Lg3!?hHA#{JDXMJcwJGlCBYz_Ba47z^O%(z{`}sIF9%)N8OUOE>fvg# zJYd2_h3TbPnKAGc^}63239s3tmEVgu_pAxiOYV}X*tQXI*=AXvxO%nj$B%M%roUDN zW5xvwS&dF)fjO9vT>;(YD`xF@Sv$QZ|04AWU8GNaZ(46=N>^LgxLpfR|0#JP@ZuBB z5y=w(PNoGzKb_zaDti0{^Xx;56ygR+EQ0_w#BUs}URxDr-?h0UNX+%P^Yg)&)wO#z z60Zg4Gbx5NKG+*N>OUvOK@a`(ZqpxbDGkzBkM*CSKbSVi6kVLMNHnZ*WT}ZlIQWqU zo5Xd^0?0a_$u_M4G>Pi8K}nGQ_wP9P(f6cdIYRoN`$+@uIMg5fQ(U}%>mh{`a?{q1PsTD zuIx}p?A3~~7cPxqzlBci8x;hweZ1hFVignfQ729v-E#blTnl^mefwoPI_H2|?exax zFgHSxsJu^)_#zZ2lGH0hAbcVggVz+3)p&>mhhK&6s2`UEq%R`RMf!67G#B@FN|!h%TD`=u7~u704b-3*NOc<m>jJCoj72jIqr86B=+4e@4SG#+H@U8I{8 z(I#SdfaP|GGzP1<3weZ&F#x4GEJ_0SuXONhzherr(V6GOrgu6CYul12Z8o90MA;us z>>g#oFqxF1zcs@+a&(x9kRincu>C!X>57K(hdRsn_B{qWG7PWG4UfG?VnX4e0#@VI z$sD=g99M{ae(qs)fhv`0i8-Mbnx%b^Ib?e-H$0=sumsM~c2pSk7=T4@#zWO6Px-%k z_Eh-*zBU}Qyy%%;<#_@Sr`;cIhC*}V#uN<@Yt_JQ5N@l5k{chS8iykqH3yn-mykHF ze#&9q{Gx5sLK_$3Y9TWJz)kw>NB)uUAJyh5{5yty9rwCr^h^9a`vwMq+@HnVohNe= zhk$2);qQ_I@OOz#>EbU!xO`2VpAOG)sEx@NU%sHb4_mbn6&usZvCT}ms#8UPSxgLB3 zJla&^X|629N$qdQDCk=i)c9q3lA0TWN+>(PcKv~8}Dt<%J{%v=Pl?hW|;Kd z(4b3RuMNY{askd`2X$?C;-o6#Hypy4FhkWKh;4t+lF=UxP0gk1YL7Ad1Y%6go;hY| zZ?pX1PhY)N{xnTa6KE(6YxSTULXf`|WjHxzLnm@;pqN-RYoBDhjo5_|i4o;>mmN2B z_;QW)dkG4~r>kjbR%F8s{W3 zI-XZf+4Cb)5ao~Fu;7hQ6I$Zop)5mGy%NZNDCZxj+Ki)mti`Cru!Kq0$P0ek0@j9G zFI5CT&RNm%8wc5I&J>JJWT!K2wUKClHsKS%y+LT1yXIPkR${0-#qB!npQ1=$-U+_Zc7n1TI9>R zaQepuwu9F0&!xaxcp>c-7D!_+H~}m?|9klp2XbRp?ga2jJ@Et(21XtVC;ofMzk2z1 zGW=^K|9Xah-QmB>#J_9fzkuNXk3flKpWDG0EaKSC-aOb(-dIxkv@p+RWjrt51}x)q zi2+>xLT}|fotZVo;xSUy)uS>MwF&(~{(2Wo)7;;4o8*Z_p)-yP%8`HCgHHgVPz-q# z5ah?V1jO1cAG2v-4wj})0P*JkAGyLRTm11DPe-OU&zgXM{{c^vx4!B_z^yli7fPSs z?%~)y^|#twAHe%RZ1#mwP0s@I*Pw;imG3hfwF&;okURmL`$GU%4RSQcXZF?22KjJ0 zze2I0<7CBj8k^GzAkO>*aBd%U0=NZIrV7#t9KlZXSgn+=;-H$&k5iO1$Hdt^=NfDhR!MZ*Pj5sht$&I>c08@V^^n^ zbH#NfoDj-nry_W)S;Me`l5;~vEyb-$M5my^G=Pm$;ajD_y)Q<62jiRDUiPpzG+jqS zR}>Bsa0HCk)^hjx3Ys97wAPfdr!Lf{i@ROD^2Hhw2d)BE!1UaB&*H zYd&ML#OIOw&Xp1y4L6BnIlQ=>&G(1z-Y@mq|<-xtm_qo zKQ=r~lOYKDd3Z|ZX#M<=svhZ)-XG<4Q6x-Kz)iBS6GuEi0)uA#TM|~YKiZ)7j{Td_ z6@>&?BGxcsFSc1mhsS#bEH^fKL$GEmhh(PvX4Sv`r%Q>T$lZAG@AK(^!-997Yr_(|s$;b_8KL*C_X?X0YIG zm))4pehE6wFu~yEx4)|wU*s`c=#&rw!hm>Jga*mxEp75R`?#^d0i63{y9p=RADxG* zl5Z5Lii?{sLTQt_*Lw@$ZEF^f(O}i)>m5PYJsjn~N3eaZ;7nQ6yO}-zt3LKqItL3P zu9T1EK}p6T_`qbZma5jLGxjAn0BO>_yF@GXcH%=mS9BT2w^XZ|ogtUBJ6^L@eILf6cp8YX+=<&+YRy6u- zHBQ;2xnaVkwuz!$RCami)9hGohw^Wn5Pc1mgLSNGNB*WN=JhbC%aXfBIOM)8?vp%PY9Pk+MC3*+ToDRp|!Q9Y-q2L@k3zDv9V{;1U;oNkcniFze`nDf|Y~d>q`;hN}FAVy>^~E?|Kd&%0L^7+o|zWdNG>FF!T+|EUa=T?X` zq&J;1Mhy%-0R&Vv22`pE>}L+aRB@biJ(DdvGMA&(va%?Ti0Dk)vg0aSqd`a20mUBB zmrjXC3NjbtqO1IU%<7K6ss@MHX}J?I(KZhvK&%d+$HBqAHG`g;9d06^0&qvmlbuhv zMZ-(7nFSx%JDzq@Za{QJMoXLY@IZk{aE=^$WUnz1{XA3B5o_vRJahcb#68^eQpm4M zZPr$)$kC6*oUbD_)&n5Q9TB9r!|?02BYx-BVQ4z6KM$8-JvJ_%OSrL+*0cE4+@n(A z@?AI0yU_ad^2_mAi5df2lYyY+kn-s4bd`k0sXpIOOn7b8pLHbIEH;3S22_#Eea{G- zY~%-u5GT_sTlS}v7v4aSzB>tB{|ONyr@e!oH-pX+?N53JT(5xYp-FDyYkAg0G@ z3uekjshQeX6aAu0Bot^r9wNK@hBLU_vMx%AG_>^UP!J>3PBH;{IDyr=thChZ>&?8yxU; zhndFO4`_Tb*SRH4y|a3IvOapgRj{Zb{o<yO3@%kcG$t9H>p)cWW}v~G}S#IKe=v#uH+KCSCp-RJg4$Wd-gkPW+X*vHD$qH_k? z8>E6j;O#a+hv}}w3pS;TEw+pS0{5o5Y0r~!5=^5g6fzZ~HEG+jGpz5!{^A%Y0@-eC zaL6lZ)ZuJxQK0Lv-K?pB)-_sfR^>E3ad@*2HMx-fD=BX$$vfaKC4ip(=YIF{x;-Y2 zK_jlbExFWp3c(c@wqzePZaz07Za?Tt`AK)y=jkJ3?5B9#*C8P8V&ZgZX2C52_ncd# z_fB!s_LO&^+HW$7W(dipFi#=>_Qv#;dlW2S6ho1bE=L%f!xMm0@y-UOyG##{ityc# z@4_-iscrGC=nA375hj5Jt!~cM@lB(dHoD&OydF|LcZH%_^`D5qjG4Q8R3I$dFv$`?Iu}`K1;uTo-fJWNUWfD z*$i(*&w5wEDC+EkJ3CDL(4FVI7<|dfLRn$f;iGz(-{J|N<^=GOwzyOLIdrUr zhXjmxfp!qTKZ*{HE%TvqEe!kCE1=vc&NYy?PU@`bwU>Y9E!*BJhJE}xE-(9rrsm{` zFd+HAE(RaHKS)|lOTzvo6%=O~qeE_vf%EJAQhFzndk&o?lR+Sj+JExR!o zK6xcRVK1i@>QbBBV{cT@x*ZpFXem;2OA_t_wSjfd90#JtrXUT&L@GT)ZUQPeSbO2b zOB+c7kvTi;`lInsXHUsLFf@kKe${KL>}wT!olxf0+EX1*w7vP>>q{DRe5qjMyjMYv zWJk5DlM>xL%7PlPz-0$7)OP_2xK ziP&7868L4-!~Xzl^A=w{_V&7RTA5GCQf*F8wo!u4I)i$UO+GpH$3bT8k7h_)ivs56 z$PQS5KR_c?>^sC(+DY-v2MO@AOSc+dYP|b>6Yx^T)PNgOdjgQ&-U!W(9w2@MNJ3vSvphIs zH;(aGQ)W9{DNBhQ%^#Q^tWeS;n&If1Tsa(!F#(zE zjhD6Dy(5}g5szziO1oXDEDnx`Ncs$Q=oCCk%c<DX8%8k4MAlJI3_4~P6rns1t3p0>{@wVS?F(3*XxYX!ocy4}mv3u~2s zOyAQ$)dn_R@;V)QpMzjDwU#@5>!7DQPz_pM_MvtJVjG zxBYDGidKco z<5Y)wFL0jbMb=#FxFR!i@ve%idz$eqL+J_NxfzGO$3bRUe%K3Fi`s20DT3~= zS@W+7+d}XB{>?ES|K`CZ7b(j)rl~1%7=AZYg0vq-&@A8Y{0eS}__*(AjaY1ec!NUm zwXjK2+=Z>xYXfOhwBCl)?r>cDQEhy>`Ed0X8;%TWU(!+NR4i!z4H3`soegF!wz%=- z;iEeo7gBpq*LP8C)=;z=SdaQ%z{se#rePLT`2a=MI0RL!(b!1AWQ;!4!h!V>@wiI; ziG#DVvFzKTXCI`zHT5NqC+`?t4Y)L(Lay3Rj(tcDAoR{~nvVIh))4y(%iu7(kta3k zN131wqntC`S^(A#yn8&W{Au1-6Z=0^>xK;yAAC3*WcBh~PMvP_&!IlG83+|Tj;EKz zvcO?Ze-xyN+412^k-?vBSM88*FV&8YX%6=qHZ<|@c$)`p7&W!2bbZ|DlNK#2oTeAH zh3eil_h0bSDxnD+!pNcvIC0e(Wy?gfT%$4N4aKLGRijOM&B7KpZLeZDsMT)2kuwsHiO_-TLykX;r~PUCSt`6Jdo3?d z^2V)#q}0b1_9HFFDZ4e~c;y(yOT_H%DNjYEt*7Hna{L8FQh%hp(yANja1@_>nPzQ# zx@O7Vv-9~cq5#N2vQw8AO$tY&+K&U#vQS0eC$Krl$ornyfaMq0qQ3?{5)AOQSYVO; z28E$;1sD(lwN!*v(mtUV5Pq#a+(2f>D{{_PV{00}!UL)22=J!cUWK6N0sfR1asKK% zrV4j#p9vUfGn|7)4P3Mqv5I<`(ZIPO%2fQB?6E?-Fy8cx9tvH!xv^l!p4!CJkJUp< zC2N+Gm3n0+@{-+kL4H4E^N8Z@icakgJ z;B-!u^zY1P=xUz4$Bu@8vsI4qt$OP7@k5?*U*x7^S32$WtaXc zRJgBf#iEyQ`eshXxoruapCl6`jJK%h_Aif_ktgm;^UO?@WuZFs$R^XFG?QKoOvlD# zBRXlICVJf#{l@ka^1CrSzez(z%{orF$Tyy&c0irwCI^#@`ombcpTfEKAx%5A6x2ivoAiiY^SrNlSQN? zklHHR5&9@G+%4iZdjDopg#L;X5Dvr#L}`bpq2Bfve@kThOJf^tQfA+*z{{7(1oJiU zlWtY2ZbMsAEtqDcL?n+VWW`*@hi;0u53D;y353ef{_M02exCHeJ=^x*plDb9TH2iU zX&mU9_Npx&;ID`JRhLHNTM{Vo`;M12fpB_11(-aVK=n2&} z(7vXYb?IE%Lh41LlbB6%Tgc=#rO2xE7mrOtkFd2t91!=Et7#a{^>Y@MN(Nv;Sq4gMolk!1qTS5ZAE`i5&se)yY<@gBqZc`zD}!#1{sDiKSA$|GHi zAUET%nEeaF99dq@ettUR?`7mKR7|5wE=Q^e@N7IRJ;%l}isT9HznM@k8}lTr)A~Eu zZu-cxa37R?vCspx9{xQ#`|WBa=sVh!$j4@K;oeOf;5=t|!}uG-fY7Sg<(v~hXEHfc z)J1-Vr}e;QtfuccdtWxeyPM*&aNk6T z$3yeREKxaH%}fNIX2xafpgjv1sTN_)R(zVE219G7Ga(Z9V%mUJr%^vN8QzT&bVZIo zxRb-@jyWC2%c?cCO*jh691R8H5LeY$jmwl#VlSV556ttBz7ais@Ou7CQmgcG#O|}2!tB)KiL4%bCbodvES!wwVEkTQ-#|Kvq zeFssNJ-V0RI~}+uK3%#KXQthr|HCG61IjW1qa#jL>U>dmL|(T~G^eEW7MO=XLH z`*jtVRHr!4>v&taYUQz%S;Lryi!Pr}G#uaNSh-rphRE|3!ZMx6*UIpG)ED zYnCG|a&RVi`TFkG1zXd=XXS=&2mHieieIqy`WFSQe6Mw1DycktW5JspAFY}@xoyg7 zPNedSNhILf8aT?tP=y|@X7n({h9ceB^u1H!-I?%A z>Cq74I!ELeGTxVBvc(1>+c<)KQ?AcUwMkiJjvTJ-gKGv&faT&y-M4>?EoUPo}pb5?0eyKi>+3|rdo>qP_i8a){LpoPu^=p zzcJSEXhgP4lhvoTjpb{!x=sMqHQ{q19P_qyL(1j8u_4yW2FZtL(JMXT)wf>$P+MF$ zRoSM0%6l?=`xC&7i!gv~d>oB1N8*9ZGix+)G*MgZ%CMh4MWwspQN3T_=;r*pcFKE) zqMRqsQY!8Q)zJWJ!e-QqHzVQ+y*HXn@D%welWaZ9!T1q@_&_P|nFsHrNQWxbNek-x zrySl#^h4-;K{`wo!`b?|oj{&4ts~lzuUqe^miBpZOA)l91n)B{3FCX^6+XQJOpR5c zzl?6uA(P1tq6`X|dgi@SH@j^IJ^>WSk`2}dh+3RNa0)1LKY#>`#Sf#j6;U8~=st}# z7!OmZ*)e3O)a3f5aR1!HAD*6P*{&zqs*6{rr9$#nP0Ky(Xi48z_Ky%16ON!CN@`{` zMt`vA%KXy$v`3fuEk)?H{*Q}P49$wDz)`9((bbA*-B@lr{X8fk$*aJ)WCC0UJI`$} zSo&t>>$^p(2*Zq>^Ip{#IDEQrRR>gQZ0dfVK-AM=SeEG!^o3i(2yoJ~no{riIs5fX zDT6>hJ0+EG26G_!{Q8jmGGa~31`M)?9X~vD6`@?l5o{>OQ%=2v5E?~CM`V61@tfo0 zm2o=*RNk5oB&_{F*mV1rdu6?PqJM-XFG?u>^R3pr0Lt7;y)(sLl)GSP@#B?DrwD{T zYPELUuB@OrEtkE{EkqYDQKzuopY$b}lA8Vg{IzyI>)$DJV)ZH}WxohhvZRGfs;&fW z1S)2>z5w4a&1|bdf@dYSg)oaP8|$DOZLB!X7Djura>q@jia>(dMsVXfwO?O>PtKpi zSv1DXB}JdTEfni{HNbPAH;zxl!3XKMdhCfQNn0<2tr6&Clo`mf{cL08Uks_Czg19u{K@C&=x*2t}AFXA0{5DyjP^(9CgJlrhmy}-n>4sznA+>kf#oSPz0v) zCa>}0Tx2Ej3%MLK;8}$_$eBoebmpvs3~}n0*9DkV%1@d`gqwH}UQ4W!eOh z&YHw(J9EZtAg)Md8CO*gwX`c7F!8^3YkEI$sZSz)fx+cYR=%73!JYh}>fC3Cy0EO3 zmYONh^!oJs;2s>ZaoYjWxVsk#E$Z+WLS=^v(}wI0qN$R^rDZ$xkiuTGk5vrrQNoPW zV~c(WYgV1iTh+JacdxvyaBk3ib>Xy5Bt9M$jVB}XmE$(Y);f9axM3zL!Xpe_X!j1E z^Oiclb*`6^F_ub35C--gs?f3c2YxHUFhJ+~c zRzG%yn$K=-%#f~DS*~l`a$OA2X3hYjFCQbR|5ab{<}PyB(e_~Zjn~6IxZ;cBF+9ia?57pnJ6V0>cs!9VD;GZuu^X7D=xmAW>c%2I&~-v8X!`DS_sFWkZpJ@A+(InpMb`FwL&c0Z!U+G zcN}3N(2vU;O+HwtWg0o;(IX0mt$2J&B#YoXQTcI&O#-I+%@Q;BZ54&2I-0bk1kIpH zW**2`BzQ!MbmEC=S2*_R|UAmgQ?kL#U07ofz6g&)SSB`yt4NJNi8SZKLunO z!WC?i!FyKef~O)&4RY*^NXQi|->CPoI@|bYzhD z>=U>K8=4Ij&5o~jxeUb%-ZlSo%E;Z8xP1J8PH8yP`HXy`4L$y*vqMhdNFLS713ODH z7eb?i9BEPQaLE>}(h$_v|E_PpI#EeUKqPOj>$YsD<)4-dCxGH{a}qEA{_=zB%smN+OUG|%KUW{&lnb!$+xSN% zsx&1sUDR_nR62`K(xoUGIhPp&x%C zS8g7)U==J7>A_Q^YRBCDo#c(D%}xR08|HYAP_@FgW9y#fyCk+@lQUHg&x7n|y^J!S zxiShQ+jpS21*|g#yVT$4sT40XK`9Q;ACgZi^N@jfRE(OA(k87(QNy+=rc=RB9_)8k zG1KUEv+={9l}pN+uG6-U<6r+A8p1p~wlO8CXEb*1Ux({GAXy#6%n$WPv>g$G>o(IK zFE>$)ZFxm%v3IYun)9Z0fs<%`p-PGPF9;yrPGen90B2iL_$uLk%#qi8oZ&OA zx!@^pNswPu)X?eG-^Md!pMEq{q@pzBkLpw_MdoQTv&+TCD*H0oHI14O@!bXddW5c z^a)x8i&<)H8eb%BrqQmacdFAa$Lt|)%$p?`RpSdku%o85yY#r+mRZRpc-n*-43SyQsm=Z(VM4e{|z z+#;kFBJY3vjJ|R1sAPT54jZr$h>sqHp_MtwvF$X$xFnXUBO86G zF8Gy@Tl_m?ZhmV)AwsEr=xC5wV&0mKqXe-qrb)AS1@g5|COi-V)7c$67{7#_g{RF1 znve9>-(%hpaPE2f3C2z2Y@GoS{PPOn*DW#Qo7$|O$k(li-|I&ZvH^I?)a6aneE+|0 z^=>AUqlzCg%0}*Le>XZNERV|^XQH=wzUCq4IWJ5>bfxNr6{`apLSx3P2klnI*MbCo zYN+ripqjL^+Fn_{a0Ro2_3IibsI6L6^j@v}hPz+LnaV003RXo1W%chjC%ndh*_BNz zNb->IlfW|X`wZs?Dr-UEla5adv z_t?TOJUn$gU4g0RoVXyEJ7oACScxLW@tS@zRauxh&}S#`|Z7wiJVC0 zT%UM*871(c@VaM`qiNq8jg7x&?)oKOUqiM|A`Z59Cd>%nz)(B-p6P8j@kGpm%OG-X zZ0iJ|SBp9w_LziAP@O?K5tqso)cXh`P>uV;$*eDOMfA4_$Np};>YX6S!d5*7h!gBw~t$D8o%EpK4z?WNdE|0CN0r}tpw}%v| zwy;n7rO&adq;?2n0|XbC!au@gTj?6*{EnN|b%~{MR+PL*zzORn;W3=1_!&d{5v8sB zx(X(mQ---_=<|W;@1~+_O)`|Y0VQ75m5qmQ*$ds;=agFLn+&zhzmaQA8*5TJD#$Tf zB0AhA0hVI9(lKq%Acbt&HSw`8#8qkmS6L?zcga41gly0q5=U5RH-3xHBjQnYGu_uc ztNwnU*OZYM8J{iUa&_v`)u(zNPnYIB6Wj@O+H?qkA!UvxiYI@0@Bi0~6oTe{t(5Lm zlu}APbkCY3vdzLVHX1guHnIFu$&d!NS&8%$?$zR4z za+G;yR)o{?lWS7~^0B>Ow!sZX{s?2~Yu{aZyd>WMJg=kSA(nVxtY5Tm;(4=qle5&o z{DEuLBW1gn8UiNW9*==+sx_Dljy;e*7nnj@JSL&S3uhrO%m8r(9 z=_F*n*!b-5>57z$tvG{7H286H-8e%j`(7_~lALnoeFNmn6M)>`o3OLwgKopeB<1kE zU?k^S=#}q7FVI<jwlAGY)dzEmA<7 zDv}3GldG*u1dS-Fo-0LzCZ*%Lq#zN^X=-N+pLczKYh`7{!X#fntZl>b{Jjp3v~MIC zq!y6}EWV?MB}R}HQVIR6!9J3PEw~N+1@GZ|wr_uOSh6%eSd`yZt*xEkg06gNoKf2N z+^FG4Pqih{`wSk$Nc}z*AZWyBTM>EM9Mv6M+gi>yv<%}%{NhbOwP_&M8=05-&9%vi zxXUU#00G&Q*Jfm!d2wNcz7qpE!4)bV>ABlyw5sea%trA`G-^I?&K7nQZw!NjJ9#AGK z{^Mw%4Hb5?JOv-=5YXu&zxG!u$$C~k-hf~%<97D9^=0bHxwDTq>`y;l$d;KGx@T_Z z>m*AHwJUFYGuC@V!<4Cq06U(IPvH-u4Z{Tsqv;EXC$REEU?9aNZPx4vm$J1%q5shr zimwmZHus3+OB1XlE;SS6cE`}@(Y~eDKn{)|WE3&!=7fV$FG>?uzZblrA5$*%%}pz%905*X4KgwQNrS z*%&_P&FB3LKY-QwnE~z10#+o@?3xCW81IppI<#{1TxcohXk{Zz&ejT~RPtd=8hh&E z$M06FWf-st(};96hUgws9Z)&4Q5|mw`nmqw&i+8=B6O)KKtak@n|v77Hz639b)(cl zW8aoG@W=O9gT{@#zMg3iR&a=!B+ndYg^q{1Y0qlUOsOv~c6rap7m=8M&|~ZY@_h`! zZvok+j{*nbs6n0--~_Fkf@;x#QN(BGVHtlmmSKMVTM$fRMreYQ_ili^`bJG|GG^X8 zetbji(*)Hb9ltl05&|U{fb*(rZYHElrF*`s{#n8n5U+a2=uf38siUdLrQnq&kP`_u zHSBO=rwSFXIx>taHQ5qauLtnQ=7+@k5~aMVYrQg*_N7i0+&yi}Z_&@xK33{MAL^E} zJaU#vs{{~W|TX9)hmqfgkZM@k#fE-ca$XIT^gLqY!E?Qy2I`<(mG|^ ztnBsnju!L;aE6u!>6@Gp8-;6e$V_3erTX(o0mNgcu@72@nV>y%zxi0j0Nqln@9dB25IO_Yw$AdP0p5;+gL` zYoEQoZ|$|O^=G%W-yie3$RtDV#%NqLUtI$oFw@)iZ zTuq%n$N}gK6F$n1!4?7&-U$;A*mEU1xGW?uaf@2S-(DZytyRCE>QHP?HeYFPRvOx` zC2r+2onTjwPE}ssK-XiCx0K`@@tYUgQ(faEPv5Ld@O#}n2J+gcs8H`-MeqaoJXBp^ z=y_bYx~nupYv8G&MjNkBYYE^n&QxWu9-q`B^K4PTwoDa9SFm*;;yiQZ1B+@c<>ta%^x|p0hreufi+QHCO`l8_iB8|=t@?2L(l1;!%b+TA z3E7kU;U}z-2{zY<=4HxP{p_JOgy~sP2LL;ZgqUpqEK#Tr4wf{uy>v`9ii@$Dc*XtG?NVI!h3xTzcf`2p_yQhI6{B^Sl%R9<{%a4MV#7*)xPe{ z141hw-+1&b6r`5GQSvUHF;3FTE5_N{H6+PSnxQS(91FR7*-f_`ADNaPglk)i8Mh_( z#lnHS<7f#Ny)Qsdc3U?~8M7YSv$Nj$vG8ro$gR)qAVz**?K8EP(cY{`jPvo;1nwu4 zHOYD+as!i37AHK(5*qqMOsj+%E|^)lsYeC zkfY|op1#{M@)Hk;_SSgoh2Gk_P_q>7CpQE)Jx!lPv&8!ii1!Rn&a9DGhxrLF+@&>s&au5;u$uk4)JKCdQue%H89Iwcruy~w$78F` zP18)yeV!Y2t3JN(swObjEQZ<)DLjyQm*3}`qtAoO^p~y@nh{={BlzaTXe_Ai_#5O> zf65v7k}e;&VPoHU1uPhd%9nN!x5K9Nqc?YV-6bGt=~fZh+gtX+CR6g=+Y=wx0$GV- zNnQk}ly!|^o@4W-Tfx;OckUq0O!}M#TOTQ%+*P7(R@7qFWJ6|T zS0RVpZa-$^Z}-|9#FCue5&Bqu9fd_SGF-%b?s`TS$jHH(X=9UOqvD%q^Z1~*8>^T1 z;G`|XJ7%IqVH3pL_^g7W4w%otE|Xz~LtfHGdS9~0nkCa+bXSEVGBXZcmcCDYLu6oK zhUnaRBU*q(b(0Xsy{-;DHlm355b?fh=6%(_z2m1gXX>{4k{WJv%|yE=fe43h_l?4F z7rpQdtIADTVKS9q>EN-{6lT9-#-c8<@lls%gc74=kj{0m0p^Tjd3FF$j20lD88V4p z=-C@OqBc9WH@vO+E`?bQ4pJK2oO(A`O#2>YT)cu9{ z%RjuIUOAlX`vzijy##lDTuZV~a$b<;?^w}KGJ#&U_)>???v}wh9$R|7dKMhI`{6Vy z`D&0YpUr_?jOXnd0cLnG*jf}Hy4fgR*6#n@t)St{JGgepsO2d4LGNujIbED`X11&{ z*asj{$)d?NsfO#&?MoOb-($%W60Y}n6dBuri$a1cnBbh9lS(1zZpjRKg)p?f`&fAeQv!x4tXTe9XaP+3Np41$PZzPkNCZaecG9)9L z#e3&nM@DdhunlIGTT{?W_H9k0-tnC^zZ6_WS3bGC&!FFH2J~nYR<=A4)4r1NVsuI< zc4hNIDZR}TUdBgHs^iCRmtzPNpFj~F&UYJSvf|2U3hV4Fs^KJk@aOE4baP+tjf+bh z@{hK}48B6OuS#0k_)~dc1qwTh zTv_@~vX{$zI86j!)Mzko=dTFexOXN<#pm>^&uQM^D($Lk7QF9?PxkJ932>q1UuYk_ zM2>CM4Da$OvU@OgM*6+-)VEV@RUauOJ&Lm44qvZYRv641{pLgAHUt35f3Ea;3bEp? zygJ>K5$yBoz$(c=JVU6X$HP5HPEW<`o*4*RLSsyG8k>Cq`F#^uN}nEQpK!zO@keN} z8K3&^9OtePWY5m59Zk7%VBafdpcdZbrVn=?a*tnn5EZu{7VTXkKnz*~)N(w3spWu? zkdC&BK3(Jli0~llJ97|O21!-dp$IDDPR^F$h_--QF0i3Qf;I#IYR*{z`n{1H+S;$$wx(=JGgNkTpb+6Qhi3YV%H%e=G4aU9PQT^)MP0 z*KA}sGV!mhDA~gnWG@-bXEJQo)+XR>n?_K1#s+snxL4|8*`?;M1ThAEytZnEpR`y0 zL-YA{E1s+l{Gj+ET2xNG?4~RqvcG0?IDQIS-QPZw(=7jZR> zUVmsbR>E*ZB=EbhKG7#sTm~O)g*leFSev11i#-pb?#7~|TRYZa@?YOWTw3TfbY{1d z`_DQ4b>A$pn0I?k9%Q?j_M3G(qte6~{fkdH1TRIOYH^~hyGGJ1Bj@d0kh?#D3ieqG zb&5wE*}__YvfBjQu4G_LSIX1>&wEcENjAa@s@}$9AD6r8?HAL#gzBBTyMH{H2WI#h zeEQ3G%cR>IYO6)af4a8Dbby0ad5!w-6@NewDw$2aqeGEIF;jhKfzsp;V1mUy5%}lZ zNF5}NoiJnp%)J&0FndGpu>y+#w*L>!O6E3|u^dTW68OiD1iY*M^-V5rR3O;d7?aKx z=dSbEWo5p0hCsb590CvZgdXjFqN}-?Ecn%5pi$z~f5Q9pf8yNwzkSSo1D?FH%v0S= z(J&r;@pS5oKFM29hQyzGzep&Y-<^D{0yOr-G^d{II*L;rjJ2LJWEZ*~mzI>g&v^<9e^J#i#E zqEWY27^wGHm_ZdgIz}>cqZ^CqcDO}6Uxin)gilw?uqiZvqIm)n@*x7mG!c95F?l}0 z_4Ltfx|Bqf=M~ap((p_gCu8fk26De)45$PIK(Ad|gd| z&4aP+f-jMl^NCi{E0F{V=j*?mk{@z~y9=tkFYNtFF(j*yY&!bMC0m2C=X6TQ-7SM~ z2U9(?WX(H$A^qBr&q5RFzn~2gJ+nI38fBPMM3sIt0QD)1%_6Wdelzss)AH8dA@X1r z)-a1Hv~P|fE-Z|uk%S3KF^!^-eAn7)xX1Es(<7)NNxD1%HTNF(ktn4*k>XzmZESXLaB`Yz^5&PRykl1Mh}co@-BG-a zXQt4n8*##CJ3ILKiI}LyGX$N=VFXH-H|==?rn(lrGdXCZyrI8*mhMe8x%3>t z2mpmao3iYah`VSaDlD`SG_R{F?h6TM?suuwJP6i9J&Qc2H>~3$ex$->akE^2(cNQk z8cQvoIf5*X*`gk-f}5 zBdR;e?MzogaG8d@(nGDf$09Aac;5)|?8EpFt{L+JEdc@@Jo~7=@U3z;&tof#NBtFL zX)z;h?yzHStCgUV??%aDmQ~?9e1N&>latK-dU@oP)lrK{-zZCxc-X!TN0tR+?EynU zS&pqvTg<^V?~v~*NunO^zd(ElhEjw!8RDD!%XFf%Oms8!v#Y{6_~v5b=nikfmDtFh zNUGsXi)H!7=F6uz@0+ohQKh{ao1qn1>`d9hwiWt$Bfdub4T^HV&?RY&C#3napENu} zt63Lize2q`-|+t1AGFs7B-I0^@DLh2UYFlltW~++_tjzdtmFUqJd~Je2h$AsO9EowP z8Ix7u&BHw|64?pQ$8c<9i?)$((Z@c&e~cPoQnQ%t+a5AX!EVZg3@sfb0H~Zd2APsk zx?VZ83PN+Xc2_XC`i*Qghz9d$SY~RK?=^J8r0l*6VNw(l1qWM1(&**=a|JI8(4_Z+ zCMCbd%=@B;id;=vIC8L>minJT1qHAR^k=Z+TxW)K9iE8|u&BuWq48w3HENTc0JI-! zQY5DRllU=J&EDr~`p zUCvmL|56iAi<^`N}X`tkCP&5Pqr!H|`x9|)1G z=dvk_!qDeu9>*Qzk)6fR(0m!(D>tX~pVM6@Nq*9EiCYr+HC8J-h$6;PRe#k{yPk(} zX_m|~!0p3x7KZJO+6lK#c7_)WE5tnC+39OfFAUw1?~YoJlW&FiI@0Q?((sj63DNV2 z#0LeMt*FWpJ3E`@NJ?$UQ#1Cf$|=dr;f*SaO@!`sjGYD;CBG@-t6HjnxmlU~$z+*3 z)%k<2_9U%=La(I00Zu-vS#NtxU)=wC;_MXU56!8mzFKNV7LHhYkTTHdK;XqQt}6-k zwc&{I>pXWqz5-+P!8dB^GELQMsy-NWCNQTD_`V^foM(%it~|*PewJ~5b7$fg4~?hlPxrdMTY^j%cNR((KsTE&UD*;Mfs zMxZ7&2(_nw*J3lhy&bjR+VVW5`O0u%7Fp!EcKR$GM$X8M<^4&Hf=lzW-5tr?Dl%%` zK;M$sk?LI5?&V8>s@YlSUeex5^{x!>{!w*il7{uct2Wk#pjYloI3BfA>sdF)RGA5P zIgD!8l+R|q&YK1!iOFmBS7PA>JC;8%DHjH}EHL+%s@PsFW|oyeFl7IE=F8-B21KPW z&vhf_ak#$WVBWhY=-B10l)rQecJsk}5H?hOC#UCj4;+(H%lIM6O-4;gy&akWA6 z06>Pi&Bz;2gdtESbL*qF`sq+VLQCJ0#5Y-AeIpn`IfJUjeV$0EDc>{J@ z0r0Ck!HUiI^`1ZWf;RR1y61eA*M(`8@{gbKqwz73vu(04nsOiPAd{%d2|#?Sf!Bv& zVht%O$5v(3?hN?m=;b7lf)0UM`R|W=kO{RhKR6B&OsA+9a8&)C6VpAPLJ@$h^wr;Y zwlkMvPLA=NyN$T(oV$7nk|j*yD3EWG`>{hR+l+q8?d+Z66=@#BAt7>yrBXzqQzZ8K z@j*&@-+XKop$@_ATcqb+Q5dFh8w#5{R!j9zVE>XfYauC;5TVa@jm4SQN9ulrO_m~2 zAdX@t6IWhG9Ht5Z#0K#Zd0vIOtjWo(md*TH7Q08!lXEh<>k^`Lc*{-haf!qlwSnjm zNElIV=o=QOn<+>K9s_ee$#u}xy1f_GcsbL|ODvSrM$h_L9hYWR=T~NoxZ|bTSQ~rw z*Gt$vjKWdj&J~nrDgXnB0Y7vQI=da1t!p8=VDy_?^$bAMsz>h4_}htPh%REiO4lyk zhXf8>t9v`?lrFLOb-Gwsh|}1EmfciGMd#r%1t<*&iwEI0lw_$H6)t`*+DC~919HeS zBBsTHDl+`Et}$}Flg-@x*`ve9-9qh*ETN8e+rl}~@p9Aga*))aW^s6R>#LInfDh&g zqjwn$$dZ87K>eULjy*%|bjAkU)o+s1mhW+^w*3aZ7Nn_N>5vVBk#xQ)rL#BZo+K1S zY*v&h(6S^$w>DSw=DefjwoOFz>U|iaqhrx4RC!`{dtYQAee=LRkhS?S5*0E}J`C+Q zG>kMh);7Lf;?VKG9aE$8-SM{7)L~FueV7R(V|JVDg~>WXhV?YbQ*rqK=l}ZzKE#57A~|F_)R~l&3{eJa4x)dP<5uRo^;8lXwPBho_^8O+zrvHxmWuvo9;f zNFz+nMq~5)t8aAXA)OnUybes~JC6_DBn)41RB^V{Zg7`83s@?B0dCg)~N>w zX+E3As|hykNH4H=s&1+goBxDe^804))E=o*!e>sB1J|h5+DBg19>-;u(POf(j-B-( zeWcz85F5_5SH*~k56f=xnAIzRSRWoRbOp@wnIG86Jb1Gu!pY+aX7BspU^d0dHIZT> zI33whi*!atr{g;e^xH8OZt-AcGs@%(4%j?Y)8KvMEmi58_4P;@yG>S15|3M$M_IH@ zxl9sKy!|LFrmPWM-t~DL!pVOttjz+TUV2HlH=)avA7BygS}ON>jjl>1a=;g8ZkimP zg6Wg*&e;G8hRL&NhoWAnJf`{T2-;Xy)DK@YrU)ZatMTH zFAFA5PX(nfU42~H$IE-gCzN`IyiL7~lIycFx;P0p0(73G3%rnHJ`G65arLW1Ctja7 zn+bxGGCM!3z8j_1Fy49A4Lmtj_%~8ZG?p1y4-tuEjBb+yPRXP)1kuvH*QE0~x=d zw6Qx+U*SMJhyX*0c;kL~f$B9@J3U{~lNytV@`zfa>g(q%Zgx0zdH5iLZo0Yr=z~$U zrby$2<%dAW5SGhFWA`$qS!qzDe^VB-0a?|EV*0t>M2(VWUUNhieTeQ%|sT%%Z zUAUtm;nCCP#i=?h?N@~DNtzcff-`-Q=vsSV?-ufD%IbWDjCAI<=hW}5gDxpSJvLKV zjOuts4=63@M|2kRNtVKliZOAvR4udMS88pbw1JOtG`ac-i4k*2EY01eaMNcXg2!M; zjMY^r=Y#;!0UPXDP-K#+>#Nv-jb{Sh7mgYNRWTUqnf#-yIHgVcISBF+cv9pj^fRo; zKFVHf0G23e0iY;xaJQu!!8Pd`Po!?ucwGLWxRiR=D2(FunVf4&;)p8%1r(YP_j|nk zKB_&5IPkS+OXGYk!SrEK`$yuP4v+H{r%4tYxKa?pj&u>t-pDUVTnXRz=X<^cqd1p= zb2=NP{dz}qHSBaAd^o~CYU6@-%1=L7yR;zD$MWo;fv2Ucp&ZqMi z^|<%bZ_t+u&&zI}^CfZG_oEclnWlbut4Rzjlg@>12@a<(+hj;AMp#a__3Iq?KAgTc z&r-l8o64)bHD-_2$48{|HGb?=d{@$e*Hj2nSj00?&%qB{p!2)(vwZkDqYHLq=Wjbv z4H2m(U`Bqk_C*aYYA+gkmFWsp5z*Nilk3j$J|kuHM3jtAnXMQxtKbE=q;nPD#&8aK zWX$n>d&HeFbc2CPt1-k687gl{(ITjn#^|9$YO8#bpV(*%Cb)@B!Bk%Qgan%LQXMe4 z*@WoQ3zRrZ~B(M*HF5;x>s*{_zNXr&ew0);+Ur&;d;FHW`&4GvDph1 zZ&mN-fz__0ezQKC=9AwgJ&SQ?-_7r~PW5{8a^@Bndj3&sAoZa`dZb>v+V)Ozv&B;N z@liz0%;@NeFd3hwujFOCG`9m|#s{e;(;Ba5) zHdKGUFPJJA*gq=?RBv*DzB3VOUGq@ZjlY<^Yy2#<{?ITk z5HqL|$am_*>>2>_^ZL04VgcF-{h>JsC4nfS{1O8Qr2`y;OSUt0Z1^gAx7KcL>cBTz z$-!fVPl?(37j8bcnH4!;OmwEQab#0Gf^IdRgTwTQap7u`tIGVTjybF5)N4^)l|!3@ z=Y?l-!O>GDG$tud;a&;-dLE$i)XO^Xm;`>4UcmQt+mO z<$J4*#hP3NmRrwbX2r*kUl^N2-k^(j2c#AKYV5?oVw5%HunTg)_nhzItO^69dnzNU zSSn@n>W%}Z@56T+*A7Rf5MB%>`cR%6!dua&~gx8M91uucwt5Y>+m zpT}KPZ)2#=g@>6836E4jrR2LCK6uuNyp&3X@#xl8%3gQMOrVd|IDcUZ4!ewyB9$kV z!;>^4J6azTF>Op1dN!r&;6f!1lU68{eetp8;>L~$rs@NIbcs(-#Fs>-mx$+N@dXeI zJhx+v|8i36Ah=nX*kjKzUlJPSRvGO zY(R0axl{+rpQinD`p6}FcJTMq>`*{+U~Un@mY9`-ow8kT$x05?YD6v0*!c=L z#|5ikP70XQJaUmDPZxeRUORpY_(u)Ew)Zoju+|QNOEYlqN$x#9y~zW3UcKcg^PaI| z$^*3bjSlXF@My}C=Idt3lWf&jlcBggxVI5=*L(oXvR@$$&187w*%Z9G+$HknsqO_u z@|Nsu#PMi(srvIq{W*9>l~(#U4t-b8kN@*8+DTo9K?8IQEdq`r@-Rz z4!eq_;+eQB;cJO8gUv^QH$Y{m#pVNa9)8mLWFUz$h@;%u6Az{;z;AUj<%mU<@fLf1 zfQeU!Y>G!}NQ>x}r#$d{%-Sguj=J*3_OkZv+e@n4$A)BBCys577#rhUGHW&KqUg67 zThE^qZ~z{h`>D~8@hZMZ_dc_5P|xxWrO)i&UDQt_{O20Bncx}vYP`dZ#+dZ|pQR2y zzIV~@1tu{!ZIu#)Y#MqOxg3o7gm*o99=({eBl8h8JF?Gv;m_&4I=Wc=jQO2Buu81U z>G-OePW8DQUY4r;{#3}*yKYjmMoVL5`RAlbHAbm#bZ~viWqkx!=e?MTSq1V3Y3K@X z18lc)IItgI!;wTZYM}}yie(+QryTJumpT;MfR)lCWs`YdCa!OMpM7xl9$S~Bb|G^p zuVsv{nlc=sH!9!DYe^j++Gl8mo^MEMoqW3*3LZh!wf}y%G2!HOPZi&+5wIm0M4l&t zIs?T=n+0v*N&wp~19=6mg}CK631rnTClLuJWjd<*S1v~+hb)}caA=9XWFvA>y)(Ni z8({h_M4=4rWLvP5L|3fnNm#^wcUo-W=P9eZrTPxeoD+7YN;iJak6IoobZ=`O7C$~r zH=RHq+7*%^0htZI*rjUXvI)5m%J0;L+S0*B=Hn-DWc#eZn1W3VZF`Dd{<@E~iWX$v zz^>6a3mpU1VpEnK zAc1cO?{kG(5fbk04r+(!Uw4l)KKV%H03n3P5ZSPbO?f+Ioe{JoxeTGkyI~$bZPzXT zs_lZynrCB(->~ek4jwCMz4dI_V+>gaV&78{yR%@>Sr?Yn>F9m)P0C=&bmcxW3Y1I(mt^Jk!G68jwUX0Pl+J12sEjFvS2wcNq2$x&9%a}&9B zbu7N-gZjx+SANF&d}7#@fguD)fZU^vnR(r3GFp+??LgqO(TjnaBtKHbRNv>lWVsZn zogY4z_=<+msC|3@B58jnv6_zVt3Xxkt4Ms*%(AtjS^3eLbqGV9118on$}q^6v+~`` zINnDCwT5HgP3%XA*Aw~td3#K|ImPk0lMFlP>6=D4&TV)PACS<7S3-EhhHly}_b00i((IrGoY1Zop=!n3(bo@QLJftp$QWfYJSda<7m< zFA$Tf-^(%gZ!`{<>S{HG&bhQ=&$5J>2Ygzt?+ZqvWe@NX+25!#`uR<952Ean?2XQ^ zO^QaFG}yxQ5;sBLbw|e&+@(qqQ@#aRoa?PYj`y__!DId1`EUV)QBk5({d_|otJSQn zK@mCjVE-x$9`t>*lw{m5Di#a?pTAYl3z(HKLfy@9bp?s2Z7=|xYuh9SX zvw7?wlMHj8&wSj*3Q1MAPtHGypXn-H<*dD|!#Dg^65LokujBIKbx7Ki&K*qePOb81 zlI(q>u)}7B`ouppkBIv*WrrBKwRE$QkHpsV4#Mb~0jbtgTyLtSZdeFL1Js#3YERa6 zY`=o&u>7W^{#ZeV=IJ94@4CH^(xr*Dn|42xKw{9b^Hak2CY*nNya<_Xz6saZC2)QN z@sL4=Dd)YIAX<%pdjra*{2vlJ=Y*4hW60zThu zAP$+s>aN?NJWzBf5@UGSznZ9|plk1$-)~!pnsCsb1La6CRjB8zVFyj@b-^ky^*Q<3!# z5)Ja4Ii?@G_5ilQiEsZM*2cfb=J;QEo>83}9*AUh0X;`I#C9ynpxg*qE{xwR&a}Oj zcyTfKtpm+`nDtL;FF?#<4E&3j1<<-E8Fo;%Q(Z_W@;WsX&@{}wMN$oNkClMVhd0Uo zKr>Q+wzUKGsxdw%dXlkFal}Qr3vdeQ|Iqwa%mG~>Cm93!?HLEV!E$I#Lt%rZlxG@r zRtMtynWn+^JI(3mf!ogp8?H;xvM~avWn~ba{H)y9z7VKOvBT4YVjhj%IuZFn4w0Y!aahShfQtGT9rZuaaeWP& zUmKM_>#9`ctLPoy)V2eau97$Hd38#hhM%VQDZtU|7(}iN*#m$08~@e$0r42H|8f8f zERJbsk-x&qb3Ra)BuHTia472uci+F@X}6;&E*^b0x<~dPFrkXx8~$@D8iGW z$UU7(pcW7kllOm<+x zl`Hq3Pn73G%G0L)+fS1Cx2GhFa$;zffbOCWVD;VA3grCVA|xpiaB2S6FV`e;@7+O? zEhmjAM-V*O$W?&yJnkP~Vk0H~9@*d5?Ejmwf6ueOpG{I^4puh0%(DR{)Ms9kB9>-4 zWZfWjm~zm=3wJqSOL_9GMCB6K4QECNH6M_JtCSRUGeytI(!sJVsos~DhOwI8@}q~* zW9^{Vcw%Ku_V2wh95HKH{jVN6|M1d_&E(U6B;3jZ;g%OD%w-z@OE&!`Ko|Y1vCluB zvM|x&Io17dLp<`IVm|w@iFS|&P<;4@vHU%>e_R;aA1MkAL_322otv5N$oE$DT1hz)^OW#i!S|) z0%U7t0m{xOuvb9>X)Yij4ao)a-9Bl6c1Onv?7N%*H^T)c))r-i#nC|_G;g9y)w@e! zDEP7P$LoJ+^t@tAsMHR6>7u-!8X^CEpGi$8ab#AvC6P1Yop6=%`&Zn$j?dnn zdnm~;??Z{K0!oe+K(0P`94T=c6p7qfP{g3z`Z7brLuP;eu!L4%6cR$AY0WmdX zPtw4_^xeIN^q;#=ves-uB2TAgElSn`Z`F_r->K>_ zhafEY3J<)d@L=2|2Tx4V^Hsh!1}Pb9C1;n}JI3XQ=lp6X+XvKG$j7aU%Iycq3p1CQ zIU(uW2OXt8N}(^33^KJodY7KIKB>{?VqlbQ*c3@IUw}X_sHps*X>ES7aa@3z(t4in z3>1L%XR0yI{x$nEh$;ZWj|jX>G}<^OfY=f0V`y2nmMc{)&tMg6si~N2B{y}2D&uFX zZ69>J=DP7+^IAOd)ae>^=R5m2`d$JuboM&w%vgk1-^E_0rRL(Io8>VzeR?sw6;}=PW*0!14kGO4?#gNr6>7XWmAz4UF6r`$1FjLm+QjM)2WQ}!^o(Xz ze5ujP%y0BIJuum8JDci?%Lbn?Dhqv+fDIKsG?10FoaQ)h*{EIth7@;DZ<0K% zSsPR}w9uHd^yc7$t?y1T_i2~!s07G?!t9;fdX3uglU&Dc?>Hh3)P=WRrduIqj_dm} zV(O?@Rz5Nd5)U_$%g8cgT5+wj%2n0%VJ!#EOv4Ji70e7#)lmBQh?m)$G}aB=mS4Ow z>2-==^wIC96$C1y7l@u__qe&K)k#^Rjtf`o8+}8aiBf{6eMN}3v+nh zDB>Dt)YS~DQT1w}*mJK+8Is?6h^nM=ozY`TF_wc+?w%AV7;)Q?8H|S;FE{fOePX4Z zQgw$5Mn-MM^JAx{*|4=NbP+tammNLR4Ox7ybAH}&#|wk4uI;RCZ6ZmA_}{YP`>(UE^vVPR>7ZT=0Pz4}$^5oa7!o5MN)>`Hv|2kx z&$8DIPz=08vr&A6N61sc}%AU6HhG!&5MTab0j7XW-oAH(X23RxRAX{CG zn;&mgk^BnJJI4sk4CcDY1LG28ggMU(v_?x zK;a?Mj8*K_HQEp$G1e{?omrxBdKgS7!-Qs3RIOF`3ud;5333R zXR?qHBYEi=DJ~?*7;Oi!96U446N6e@_RXoUjdh7o85L)~QaPXdQ?c@)zTOIs?>*96h_Rhy?&lU z7vXTetXNq0bTysS_+ADwG`;;WypgG5ohtI~@u;e@Y;Wu2D$Xf%W4g##*hryApNod3 zSNPId%a2JbhXU37SgcIuGw1@7kjW=_)k3?y0CkwSVB1NTba$xH$3-S-^9<7z*JAWn z{Z^OJyZW${Q?S90-4>PKAbB(aBGu&vz-Fcfx9}lvBPEI@u7_3w z4ctnpWU6+>*xd!F!!hT>NTm4BncD!(4%nex*;9LeYB7D~YlD#hLSw{hn`asc1Jj=y z-?L+nu#{SWKQWSY;7-mCz}K} zGp@$Zy~yB=U?Ed-9uw>(z_MPu6tAK!Id#&6JB_&lL4I`6Dnxpt{4g>#<4Y;90@i#0C9fX)j6m za^l2NO`4-tiqbWa7sY~d*s7|1x=X(7%b;r9<{1cSy#?D*Et5#)ZXERD-jLal5#RL| zLJT|-kx0`Ma(v|;q%WiQ_~AMc*KM?`Wkpr|y-w9Z4cOl&LJr~wny(|&Na8VG=bsYe z!aQRP3H>0~)RKg|B_bX=Uc%T<;mQwDHHl{fcfCpjz|JotNya z-AtT5(h2TN_C*SWJ0$KeKZ#g$cS(qs9_o=8lK&JT>^adQ?}bT1iq?7J_KP>3SV+$G zMb6TbbR;#pL$ zxzA@@OPO4^j$dkWtOup z_6O8duP%MzyVZ(u2HbK`H*~X8%J%8YuAU*PjDataXMtdg<+c7aP&g*NS1%LjD?O@| za%sDI(K-66FnHWDF2n87kB2v2`-^0El@{*m8!lSz)U}?YhEUwPMqWIa#Z`ep+ z{;LDqXd5n})W<_oXs&Ycp|mj-aWj?NJB(^i)mHO}xDF#4GTvweMp0RaV_H8@e(0MX zGMZXyGu`e~=QMLmIDzY1Ww%3!TC#^BQ?aLzw4N9?Glac;2Jw>Y>+lH=NmaW`Y;TTo z@IA@&Db_EuL7|&QPwj}rHxDP#A%a5}-5&w#i$)*eBfl_qXd!4E>{q>7dzdn&L6978 zyYZtsSArHoA4(&2<5p7oWoQjnl2Y@BMxLo?qPZNO{reUsr`~@%(w8bHcWpKW@d>t?e@)h9KI}yDqqABXH3BP>kJ2%+;W;#R56s`6MgLOsuQjFaA(J!Gqx?v~!GwX!cIx6V3W?GK?9k^AUz9mI+- z0@5CIjI1t+Im+|yIkOQCKoY;G-iDjygnRO40p(c^Y9p2JUkI9aTw zx>a}bEMbu>=xhw5jcTF(K7z=e8*Kz{Z>LPGPpzvknsh4h>>20LWO$Fl6 zS2gB-gjo^1RVy2C=b|VA8^rR$m;Hu)5)pY;Joivn9;76^&nzi(?*Nvkpyre2omy%G z7^Vs+WiEl!t+#QFgZmKw4^U3<%OAfMwG~URR9-plZ@kxZ2P7E{{uZP5*+`q$A5ul94Rw)lP)L@qVjksh~zn1{33;JEw5 zaWfrpi{Ux+w|Ym-gSOz~U310{eG_BsoY;jLb3A_Pq1>wylvD^8{sEiQk~-%Zm5N&uvK` zLnVigPG6cYQ4mC2dyKfQt`}6Ov)uygw{{C|r=VNdTpOGa=F%}X^@Gw*^E(X12OZ`p zO?&C*$o>PM6yV+>><9)rt&^P5o=gzXkk1L`;n2RWI2JuVCD0qeRRw*{wna;~fV$5K zL@*lu5VPcw-Mfi)1&m}cuUqQ2%o5SyiENu9+*t^!UIU1QilfMmp z^GgMyQ6afx_i1ha7Ft)RIm7p=;=P}Im`m<&e%>yGAc0#spUrLwcd#VIzR8ED8c!VD zRt0P9V2bxdA5{VjLD||o^pv~A`uV7q^qRuXoSLn05H|RDZlKT zY}~}V?9R&t%;dVwW*YjDbRwH&;jCY)<<&$;6=8_Gay==_cP-4`P6Wp`4BXgKb26#B znCEfL7O>!1LHC7cu@gIroeS30#0K^RHD7q#dfk8iumoOMBCnQn6xZ*OG0 zxx-cgV5a4*)lG56IrYpJDOy7mM-+e%5aV34_{LP#eGAYOp97vBeePnSJ1wzn+7@9~ zTi)xpmd_L>3e3Zz?9Y-q&_yn@yuhd^_e)pjW0te?*WzubZJ^MYW$jq28~AI}XPMdl zB5?D_xuK9ooepI=^>)laP**jn0Ca~gvp0!U6zb%IJ<@4>E#Bbu;+5whY1`Vqw7{1R z34QW_0bs`H$Zk~DwAFBEvF?Uctfp%04xLBD6+MyY7R&h?r$jEO061YQ z)2ja-pdbTgPY;Eabo`AfF($LeaAKp|-}q3$|3t3yK|P@Uay+vBK}yRB-}F8QtolIr zs*g@s392Mq%!n8BjIk&I(XtTY& z$n+Y3{-Z1pW38-FU>SQgwUb`AkQBQLFQn^0VHh z4}WmYvA+3vy)Y8sRxy0V@Vwv`A>qPQ)fJ1f9E1JJQLvX|uDZNA7Z>1NSIYe&Ik19$ zu0NZB7zzgFq&ibUWmNm@jeb>W>EF*>(nkx=WZ1F`-iX%i#_^`!{t4(cy2qMDTsZ>o zHWRZFKDU;*-h?BM-DV1!kChE)eZ%i(OV_S&e_4GJe*0feyg$p)UCek{OLejQ ztk<)XPx3R5=U3iRfVRX#wo~vZ%j$uw!6iz>hFA(x=tZ)3_AEfPf3dKvA*VcKjb?8E zBj*geNdU=6Y|zli(Fb5{weD~9ndQ{M!lpTqwj z_TDq7srKs^MnMn}5Ebb~s!~;&3Iq`8LIk7+h=SA@BE1D-1C%1t6{I&Qp+lrbx=54W z2??Om6Ka5v``!1MIdk-x@qga>;hlLt@dem>U)QR?wbpMzrDWLOJoF7)loyD!lOl%_ zH`{;-Xy}VIcf@9je+{!*yY;jyX?a*fSX18aiTOCQBxCHTZq(HWQdS{mj=?-@e6gz+ zEn=*w-wg@ilVn*FV2+jlsDoenJQFj+{e~l{r`5NP3}$R3^eV4J#5S?@T#AxNqu7wd zJJdK_y!=hu-uWBoV9DX7T|VC4)#LfLH-@HW%2MU#j$?X-Z+yvQLMA8kU93@)U0uSB zy6~hIYxx`19X-&@rZ6>-4$0!SqY9Fjur(t{wft8J6Sh`TI5(?O%M#Z!3JnC)o)@WX zX)gqvzbk;(`V~>Mnsn2#!EO`SW5;)MhdBE-npdF4y-HVVF6mAi*gvL_JyB5qkoP)+ps2_@=s54TuUha#>@UipLuQ1?sI z6bQ4;$Mg-{3xh2!pwP4^taj`fbI^p;A>vx49RPyBCxP^#3*->73IAFGVAe6!#}9w` z`kFuo_UcU6i}yG&Rt=#rlgcXHlOI){9T$3_nBV4zr~W_=iYeY5_>SGz=n$A5%h2#8(jOtKdTf5c>loK_qz;!WuPmGmo@+e~Q9~u`75W>j|yr%NTwRMbb*CdwhDMsUC7)ACDrH1?-c@@2D3K=m}cfy4(DPK^l1_k^6}93)iql~0tp|^+=HC(3g0gUcQl_E z%_Cttm}Gg}{-L=bwS(h^Oy-r?og>zi7Q243Iu5(ZJ!Azjsta;GZw$IysmZzhvamZg zeDY7JqRs(XJTOm5W(JVQ3wk1$sj-7oI4MAwj(~Tnh@OYCTLKHf>O~U*UkWhIcIDdG z;!bt6XHRiO3e#@moz&Yy58BQ?bZVsS%3!5{OZ^cGuP%iMG>$xKB=sj3HYa?4MV%hL zTsxGqak_D~P%!p#y3~c5*N??NmE{7|>1k^HN77Z0fT8N414X0s$I<&~<&6~^V6lQf zU(BNSpzd%oct0w2r_pz9jjftPVtQv(HZA>l4}>6f)W(ylI=J)eI6^59fVBca=T?8I zPLAxmZ8U-~|6$-8F*h|wxR~Pf#y+j(fg`h32r-3&+J?tU305Ec z9wrHkkFQaTGMG@#$I8~D@I0NtMpcAJttYFDd7|Kb!Inj3&^g+E!YN?w234!vwv~3!! z_KoQunkN)6%eV@MUlx*$n9Xm>-!Hy%aSE1r{5^@$~(nh5_r z7N@XVXFjm;=9^h|exR9Ov)N%s90d+jQ-QEr9!oKl7&LoyQ}q^r%+w^uEwA(xh#PsN zuZPI&BYuv5Kgj5?yFlW2zMDZi=P%wO830NoVl9WW%V8&_x$#<&){_I#ne;yKY)K}l zfGAH`nC*oQekqc8#I9x6+eR?^cGZdfg2OvXb5I3zgxS_z5CUfY5Mi%x}pWa&E!he2Md0E1-?csHW!H^#7d-cA_ zr1Ohp`=eI#z>KJ}T^?n=JKv`o-}Ug)8C@uk#ei>;}P?_O;}PI zTiuL{-yL;b%1Q~%IxSm;Xt_xRZ>liO?~cr!t|x(C)BxcStgSPpAi_u5P@&_yjEwiu zXo_>v(Ar!H-2KHBmG`x}D=YBUS4XLzv1gW*_Hpzfyhus@v*Xpc*VgnEdA2J` z-aEaCqNMCocADq>{K(s#@P!y|{~o=t!>*2UoGXeAE`!4J4I#zexg<>?1D~<2u%bj8 zRNKoKEqpO`oZGrCi6*AdI~1lN*_~HF*gCydD%CtnIM)BcT77r$pu9?Kl0*21>B}%) zUW?$rAD(>HhGc+RS3FRLgrc(K_Lus8q8k2fXh-=KZVf~>vw>=e83f1Zu9rWWKaYXL zr`%)_VjqW8XFrduX?&EKskWy>eSFFW`RG&wy5TQu)i~Y^Y$tT#815=sw&UL79_pU3 zGi0Bkx(^i}b?LSTRm2r>+~h3F#s1wf1W)N_JWihF?D~fW&fD|@=Zl-$Jj{?ongg_G zMJ}is+Zle2x~5T|CW+JXlMdysqS&qKj;$fq|K%Ap|3C2zcTKES&Cpv{yC)$&WlL1( zXw3EF92Jh@RU9o&Yw$^_iq;+ktq7YHO;O4)ufWuY85BD-7AYH?^}K2>2qF}Om8p_O z50M5W`X&GfyE9gu#-m51$K$<^}_nY)*?w+wVab_;c0{?HS8CI3F{`Cf*h zKirjDeuYOa`K502&?X^0h-X5i_H*Rd4}P61X32slZ!mJvaraj{np}vO-45Q0kvxEq zc3p~YJXm5G#LT3iLNZ3!$)&anf?;`SmH67w?{haW$i z?s)oDs9p}N0)EY{B_!r6_>2Xywn?1(F-~R2gg081Ab}qJSH-PLH)a9yEwcLth#_ZI-chMNI@WDba-RAUee!NefOQ2a&ssk_4%BnSqsI_=+T3G8$j>!3^ zG>acc%l=U2Q4@Wygk%d;+H8}m+JzK0k34QfHm0Yn^eE$+OHzm!+4<-?MRIs&_JZ;E z51S8Q<4GW%_y?o%uV-cWN*`9SHE{JdTO(ZZPK!znR!02T0m$uQ41m=ve0P3ok;3wL zQ5UOcgCQn^W*Ry9hX@ms!;l&;T;=|9Hi3vsLSy`X&7fbF% zjHs9aD*+(tVFKrur?hwkxq=4W49Zy44N2)9Q4v2ur%KVpV(PhIVAz+9#J`J>@8Aya zU@3Ac*gbvXF)s8U8fWa;Wxx7U1EHnTb34qJRxa}$rN^5QhdIAT0)MDG zOsUv&hqCb?c)~M4VZT^F0IAHcKtgpuUk3JLoC$-d0vn*+4!#V>iwt?ovg(PxT3v+b z?hyiFKc3pwYX4rLu4h7$e)21wVT2+;{?RvG#xPxPc#jea<{=Qz_?bOVZT_5dy zsVHBM`|6J{`=S!E;WtKO=!4%Aia%b^Y}e2g7Q#ZsMpHIFp%nI9%bFDSCl3R>Rh;Jm zVgfgc8@Y)P1U|De$2YiJGq_mWFzQ|By+4wE(@9TkE6*%e_u&2;cIc9cHkaPOU>O!~ zgLEUOt61RSD69C6@x2?r4og!rXWYz(C(tGL(I4a5c=E7`w`Xo$ct6a+)5crJzy5dm zmzNgxsX=cIlLlccXeY@aG^idJX6&KT&I*XTrPyV;5|as-)>+e*l)bzKVArf$edAbQ z)NS5vOE+mB<(lY)6-F-1D-Evax+!<#)GD79w+B_CTwhZH2+_&}zfhnCo9;FZ$&bz> zTeRwVz{|3yTQ_|IJ{cfxAoT7&w~x>jGd?{f;;erp@Yg$aiD`iCVDPXI>z%cvq(Ttv zpq9Gt2jr3%KQ!eC-yi$QisDEv2=;OGI+eCO6w>Dc<%VNcQ6tPc5W*>8l#=@u1+DBDDypbf5q`^4b>CiYWU9L`U3zzFRDN z9w;~1(w83=?8y&WLPTz-+JHZyTmX{Z^wV?KA}=R=xWi8CE8plecau~QRs9}Of`l;d zC6g`<{iL$ZinqGbzkvhWAWraS?r%Y=X7q`Pez2g}82PX_W}*tah*xWkYV#=8DVG${ zl09qNPmj*>KiGoLbj0+|R~)=g2M3P*X>p7V_K_L0u9R^g(GWp$0Pd%5HSU5x!~ACZy5=2Xn_?4cA*iQ-!&*MMQXyyMG}=F@$ZX@@Q};Mvsl}BB zO>Y$jiV*)*5^EUB^(tDr5kc@7SCO?(ampeDv+ybIn$Z zqYa*f{+>Cx{P~Ldpv8+8cavx0j%Bxlmfb>j>KdnyfZ6YP6u{Sb8>u}A#4i>PNRFE? z))nA47Oct6Uir((Q%M;f1vl&z+MnA$x-G0Fj=V;9k|&?pdZ<*o`Lch;{BA*LBU_pO zJ+cfzujWLtf5^hU@O#zNfs~1f4yo7@(~US?ZD3I;iRF0WG~}1S?*YLdxJH(wPAt5P zns^npK0rM+`*XXdncOK$jKDZ=fvm~FM4Sn60FY0oyVb;+v9zFk7)EZTC?!ZFDN;3b z|Km2LE=nbapD@vLaWi5M`X-)F;9pZyb?@{k) zS0efS_c(r3+O=Btyleb&ea(xN6z_RPxa<>dmt2|`w8EDe=xD#LoU(}DMD>8bcazqgE;-uVXREHrA=NWH)a=k>mZ&t!nRIlZBJl4G~5gJ(^Sd>rc+u; zss0O{={JTRHCK?HC{01zAqE*_9q$arTyD|Y4E{{Iz?Zmg_W=*dR6(g-|0r}B8wVU)#i+%P8A-L9D-!94 z$@^(Qb7%-^z+g|spksW1_;c^w2=Wm@jTJWaGkjN;X?oe(a4$UDX5~k#%Z6Co#w?-s zt)B39+qB|VD$g3(>C^*W1HL=5;h>xUBCm`V&s8)_S_s=Pv8X9%xY&q&-SAlJ6^?s} znK}@JjImnJQ-_#ik#hU9p&_sTRER$kVH-^3%gOv^L)_5*D%owi84>gWQ7{*0u;hpd z4wOW`{CCybITguy91BuyjH5lt7~)Ug-y)+Tfd5nz6zzPUQOe8G#tw||Kp;+@ILGhS z&if70cf!&FMHJQ;r6`yBvjE0+xU9MdCJ>quoVL_QVLh+$G)_|QV$_#YvFaj)izmL8 zq5gb9GX>XbTa;l2cw{Xs=h4#!v6_bR0Q=ZLeC)dypzLZ-ccTLHX^I$jZ-2a?@MCtK z<1o0N6~H`fZy)l;tgXZ3)y~Kt6*|P7-Zkjcw5Sc!k{FzxwHxp{nJ6rIRA;)R@-{ry z?v^wD*s+dPpc31iGD*EWm%g@h3 zKK}Wdmg!uBDwpSLM^fA71S-EnOzL3MKZfNqs(1>gHw|RIcJ8ZAR)W!DyN#$~?;zwq zG{wq(tKL)~O#GsPA#A=CCbtp&Is*N|RFnzbrNWn*{U{N5 z?*REoD$yCj{U;StjMg22 zls6farHL(l2WOX6NK<7eSMM=@M_{`KWUrS!?i zNmF$rhPdem0heEibS(wEfGW{Cf!q$=4c$go#s-eoi|I0fC0gC-)}ZO&Xvm+pq63uP z(8I@|ABU4!E9he%Xc^8u8Qc~kjdU5?K<~f>48K!6@X|_V4L}R78k-f_WZR^%GHgua z5}beu3od(V^vfw$|8yH@XxcoZ2#Qp!B7^Z|tQ}cM-Qh-|W_zoy%wOpuHBCB^OOHHj zN$AP*Tzplx6B5Flq@IE?0I>YO$}oRukYUp_z$S=LOIxAt-`J*r->Km(XVV1caj!vF z$=pBNLEM8o$55Td?{M!^O)vx3{5Lye^}|mVu8K{-vvuBHu@X*9eDTS!Cx&_RThRevENF7ci#!?)JeW&bDTehtGAP#eYZK zs(-lI^b7#`c+;_6=|J8j^HrV1^#VL5dm1c)+@ms)n5bVfIFf?-cNGRMS(D?*5eaVE#dBV(Q!$>Xtli@qcG>qPZoN|DI&g{%2E4GgzubD4;|F11`He z*hAWJ%|A?I9pViK5$7KwAlZB19`TWWKus(H=%o<+UU_%yXRRl3Oo9I6>-!B?WC<|>5q?O7>-FPy#5&s+M7ENOk2-` zxteS6e)4UEzj*ph;}lqMw>S|<$SV4QEf!w*{m5#x#5bJbuoRFGFa<%Bb(5|GoCF~sRKRmH0tbp3LBBdE(!r>*{zmp z(Vos0-fpeah6_s>t1EKl9(eVLG@Ta{R zf97+|M~B(G{c0?}-Vf$spxcceLTp%2v0}k!JD;E<4S)Wg_T<~#08V*4nm05tXR{nA zy}KTr*diEU!SS?}sJ>cf0MH|=f2sZMW!=mMED<5*mtA^EfMr~0n8VdW?Vx!Jnooyy zt!Hj>nuT^pX$Po32kYZek7vE~FvlNNL@(HBAcAwP&~KZQ&(kH1KLC@D4WhfQUQJ6& z^<|vn$)Pp6jafo;9tA?64{o{c*X*M*ehs*llwBkj8xZY6y-HSSkb{13Ua{)Yl*YsShJAQ?M*kQ`SpI&O)ase_w( zXX^Za@LRo$+9DzER-~-Xt94@8SLP(fsKbad=t(&QybN-Z0VdsOqs_U;3By@f6 zw1lYNLDrnHh)jNro9zZ*ztUM`>C#3|GJ5Ff0DHhd4wzp1Cb^{cdQNDRdYT+>2RbqC zQ+CP5a9-M?-NM?ptO#6`>vydh>QEok>wE6ay=2;Nkx$$&AKZWPb#7?q7m|sP@rpY< z6bUB?unSx=l(J6VOTu~-LRNg8S|Kk3V|CXr@;K_AhWSKaSL}V1z*PGYH@Yc$(6Mre z90G$oC~@G;Ds$UlDcs=(kDNaV2`5>~rf+W8*&2y|y+zs$mcfWA+{eN-Awl`i2bP2F##}VQ}G;Qm1Bt z>OtcT1CP_s*|U<=y{?!%=WI6M-0Dab=`LPLP8TABNd^S?K$MC`p8s}RU}-VpQGwNJ zGMm-%(3DZPgVWw=0%O!l`SlCho8nWSZ(a`-9LdaEOg-p49~d}5WnTAj2IvLXWhrM| zZ4Wi642UvGHNA1^nnIt`>x9PW5_jt{Q$GiVa_2LQ*FUh`Eezv~Q~heB{^SKiFGwC> z+D4F8rdwbHX_KCoxyy~irMuG;yNvBAqT5=RBkzSh^g3h5`RJy(?fa+K+;a+Rz#K5{ zNUcme@=NpK9XNszy%8Q<2!ZOI{cbIFzMHwkD~y#U&V}%r^IQ3xnSK)e>p7DpJ5*qT z@IlWg+GNyLG496!rOr{dd2pvWd2o7Jl9k;xu;6tK{LIv9->CPe`J^pekH?Z!ORtUx z4@5}mm(lh9n`k$3e8<%8X3Oe8OyvB)xf!<$Rs;CxTbsM+M?ZqOuP#3R*b^YpC&<<% zr}sl&)TQlZ*aIfJZla$aNvREdj{KYgEnW^(3lp!cI8sdYSxDVvGh`l#YrF%khPGUc z&28`E?y5^@LFa#p_`-WI?j$}cTIXNX&4o04Uo)q9 zM$#=J)v`*gaPps~Z*&k><8;y=KeY`V?7HEooqN2CJdfwlim5wJ!`UVb6Qk@N-SPN<6Z8Lt~C6SsG!2PKC zXD@ii{(6R-_qHe_Kux(XdWjmcRk`c*vCP1SGF^G#vbzJx(0`zmtmojW+8ZgXb4EU@ zynU#S=5eUjsrQGc=+d8AbTbnW7u~qQZP?fRH|xd`@>k_m*?pK$XIF=oldVB{DldIz z`=SiFXEm<-Yv{;y*SxsX@-cFpJFILTciw*jc9`*6W_K2E1LdRIA3LBYGKp)!5SvDRsVYv`^ikNbS*{7uydW>|KAz@D1F^h3k`wWsJi zA4IpNw0o|ZncY2=_Tj?=i5Gf;G~$ps!T+tj$cHXh$!OxnwcP)v~IA=b6EE zl{h_-cw2$?T>hKnym{0qNruXbc!1{LPUWcs=3%Wz#(t@lBR}>rB5Sm>Pp8-udgYrQ zfQD3H4>+}v-96cQ$D~LK1Tz!Jl1FHg)f<-&=3iOu8QJx^Qdw&`JEg&mh<*FrS0c(W zl~$_wW93Vv&tKg_vvXDO9c9S@BrL9mVL?fi$FjBdIJ# z&4nrE9arONr3#kUL=Gg7= zC8u#5J1PTi2q!@u5q+{P4ajDl??5k2;=emz1elMDsp5Bfj&#>hcx7^EVA8z31%3e-V$nlq}tu|`J~>G z9G4g;=T+ewpQkvlnVmeaamw6>m(}TEkHx{Aoo@Q~e?qK*a#RHL&>UO1p2AWGC#WjO zqJ4LkkE6ysmw2;!O@(f9nZB?%d+p0R4XMv0_O#OKdYfYJ_*9EVxztt=v&Oh0ze9&i_+39d)Z`?e+f7`5~78pX& z@Zl->M(1g8-Ev0mG>w{2xoUcGZkPC51t`5%*=q6}5BS8lo;2U({2S5>rQ1&{8P7gr z4l|to%ITysu2SUL0%$H^ zS42~Qp#ZvA10EW)!RMC~UYr(w!E99)-N-}mS94Z-sm6fx;0Ic#$mU0_hOl5qpQ2gl zT?Bupa1eLwMIiJx_FjG5~)?bl8cSoKY)Vk#Fu=6dtK}>RbZna2=Umq8CwC*r1J3%R1}Lgn&lNl5QxLJ4%TtqyBBM=aH&4A4$iaD zjdGtly$0@C^a`9)(JDrYe(d#ooX?4NU27QG zb0T^JX*Ds%KWG9J4F0d`oBUH4iNc#q1#DLFl+Z{56txuz^qwT|!j9Fab}Ls0UHNVC z^>LG>V`i1_r=Og?`10w6Z>&k2QO#R7?!05HDb`s__@&VFHzQPR(ux5AW zAqpo`8Qqyzw1lhg!xIHBqsQdir>}?Iziic&nvXqv5C?$WDD;z3VBYz7Fe-zZSb!S% zwNhB(HdfCPA5@4lShSo}-&ZBT!&y+t&d&|Z|SeF9VEF4p{-99ulGiaVuZ@o71^_V8_n zPo|v#!p~Kr=i*oROqh;|=1Jw35uMd(x8(}?hERbPN(C%#75RI>k_eugG`49jOP!Q+$hS7na@gvJ6R;&fm?w}Fdzi8{2VqEQ8s z7Ys@Hu|*mMUu8rpg6{;t(b6@LfG6(aajHwBm(xtNTRYdTHGZ18(Kz0N`o9z8yD_V2 zgcH~c)^LUX;g;k7HjX)%KHs~dsUcNPeHM6YY*=u zmQj5nt`sb5MNwE2z&8TbY98H%BvcMRc?64$d?WWti%Xm~{>!ghs>O>4<{6|3`2)ca z(V>VrbdL?lCHLFP_BN1~j;qZT%k5q#_1p4EGZ$BXe;5|q`_|ofba?+!@cu%4SPjuI zuM<_}PBAOc3TP4hewu5p3$A!0}{8SR)eH|esD7sG_>r7v_PPn{cffZa_) zhif8r)+MyhFtZZZpX3W$>T-Krc=`}xLVkgjhYKj;p=NT9~>eG z3w_tSmxP2H0z!En9z@j6T%3DTT8kM#kZ-#vgWN5SAt*8*b@kZCOhMYNOFQhU57TrI zXI_f3|J)C=Xge!T5sw$`jvrp%Z_*Pyb0FRxO%k5Ry+lGiywKKUue*)66)GG_d7E!a zt1W*vZ^`Ul24x|5It1ReKkHa9fA*R7=j?Y%AXyC`4J`ONm0%}GvTZjc9Mjd?`VT)y z*KM5sWj`YVix=A9E$a(?RHukhaJB86eVnsjo$<2()fl#Z=}3&agd($2(5U<}nYhlO zpbwX{-(Ga(dh{mGlcxJj1k?4MGmNSn4;^vg zr#g9)K6!JMf+UgZgOsa;nDeVCc8L!2hH}OYipFiXpq!y&l=`OvyBOL0x zafP|_CtX6PN3z2D(7>iCDgmf$)p*(zDvkX^^U>m`>;TZJqdrUS(H(YIc!m8Oe=l4w zR7O^|hRGFPGNg1?Q@S^z0(3HMns;r%cP486Wo+!%vRKgVyDA#Q)x9^vh?+k0ZQUVx zIEc_aZjmndxk3u!?cR_$(7O_1p;E3;MpCmG4WVBdPv`$$6KwB8%74!C0Uxt&XtH`A zW^3!N)r^Kd#94t|LaHgQ0un~&@^D?zy>87u(c zL3*rInq8=}Ue5M7 zdx`gMRCCZJ=^r<4fS_@_O1Juu@1Mvbg|k+hRVqlAnppDSB(oAiMYu_lBrOb7##c@3 zcFWb0SxVBz2?5jrQ(vxJT-Y>!FxH1yy`qI|+FW(2*|XUjJ3>uYqOx}9;!7bglNGk< zjsPCZnVORPjZWCZ{)^`wZV0_GJ$qdw=-tO>lXRz%#ssu%OQ3v8ZI#cSD(IGdX#UnL zaqDY^_O;A(V2pL))=l2=zCcWr4{TYGxcd*y>l#~S-SLpCcE2oAqMMFd~xb(uRtJDs#ls=tpSj8Fb78sHofOmP)nN!%DCle!u(eIDg z{=k(g`uKR+(=&6)O#OAUn@7q@Hg)$Bz+sekY`tN(b|B!}{8RKFnp*72-jPuRNjCJ| zjK9*t_*)7GeHq#dcXCvLa-SEX@z#3%Ks3+H!=rLPx@t)8LQ88o$l!DJY4W<<7<{|v>Ty;>W7)B z@mniBzaLOGjM6SU%yZxPdgxYn021`~Wg0j)J>xdE7meR}YiMFn`6y!WvEi>i;b-q9 z`!)VGPLbv#n}8su5|Z0{VmST7^+~v}U0d_1Hlyls8KLUoyy~>L&b~Fa_6uC25ADUG z=fE{jpE@vcbgACpLjjB@)je*8K93&_$xqip43^RQw9i-Hjg!bM4}sd%-sKyh={Z7QbS zR!6(0;)PS{<1!Df^q}rG^+oB{YW+|2_y(Brm)pCzE`js@enzC2H#KOPDwEZKB74CWYM97IK?}i3VD+ zpa7%Suzz|_`?Xa}{K`~#qV|{Z0}sw-i5AVFSrOW zQn&=(DDbG7w%7YbuMviSR*i~`OmRZ)Z*em;#9dhgwwc8F+fLG6GoRCw>gcf3^YaDZ zz&eyFa{Q6f#&!Udh!_F_CxIZjX{66W9}m9&g-^66L7kQ%`7C5VS~sx$m)x=7Qg^g6!&^GjV~e(|RRwr`wWKNOm(BSK$bzO^ec z2xXH2Wf2rO0}#SZXQP(A&2+w$R#3lVFMW6~fes5Zxc-HW8&o+~n=Y!N}mZ=JnHr$x7U9N?is)j+h^GFK8RiMW*PSQJoYyU;woh8e2 z>Vl&IAXTyvP9xl^KC-<&Bu$zU_%!}LF$w*oTWuUDwtDUg zeH`uF|4tQ_JRAhR38vrhU4bhTR(#b~tK`B=i3^EK2=?0PmeoZN6MY*!XprPK&_1R{ zTjNi$C|WbvmdQ9Bvz|mYc#WV(;PqTRH9pJWiJHf|5)cBdaI-d&V6@Rrfnr~}W z#$j|G~_D(hZ@4t$Ooq{&iGpzxs6CDPK?2S0fztGk_dti6Y~l?u|pM?9kao zS4k?^^SL#O&k8@TFtpnR#?hVx$Je@JS2{Nv?u@wx2CfVpha3r_n1O0r>M>|F15Dm0 z2!w@PB0H_NgQUlwAXT%cS?BEBE}R~|DI)YU$?N4Sn<{=9v8qO65=?`bSo^`l4^CkE zrEmp#6d(^~cC*f$3>Cyq!WXx?&t4O7RawaD;yMCgnO5Zm*~tNdb^*hl-RDEQ=Rn}O z7-HK}T?f&de=i($;7Rj1x?F?HQMJR8^fO8p$#bMS0nYsz3p)%=42#LF{`fq|XQ_pW zZAMa;_vmt=1#)b(6cVuQ<+`7TFqtxl+tlep$=E>$SA6*|k_+(}-2VJ1j%CcScsQCm z_|tYp(RAW{W0+$q(kuJ++T*95*w$c4ne7GK^K}&te8$CSbHcVB=|+xAPr-7spPwH- z=SGag*p(FDcDG)X-_|n&`^Ieo^wsVFt82(mg9{XTZ@n=)gcJ}reOJGx- z#jc_$?9bPKj)=#JzdjF6TA8zt3$S2lx;dp!wK$qf$5`Wx3RlkWha3fuH*8gz!)%AK zD3|OA3GM%>S?qmCfGjUIMD6epe0*XxuM|e$phN;t{`e5;7ppqI*Z*79J6kP2dV7D< zmO!QF>xlJg1GNgxV3b#pjcm#mqVDs%9=WB71+R*moU}J-SR~yeu6i4k`ylG#0W9J& zVE9V)C{v%|4D$f#;TiIWf5!xw+PKwT+vfU?mdFSB%k=1FT>D^5yHx2laO+l?dQ#`y zwsG?MB@(z+Wcr8TlgHS5!`KS>%>yEksbM9l7kxf^SG>vnRqy)}A=>=m5v)K%WXMLv z{9TFu(ATBKccC&ZMF-ygz$48FUIzG z0l>GT#$I6>pB>WFC{i+XI-jA>8>&vn3v`;q=UKh^4 z)@wsArLD)cYAe1cmIkCum$3UwsMeS@LuD#b8np?8#-V&teC{4|tYidUHXQ2?XoE%T zlx$ctPB3ZnNB|r*yv`yQ7T;ss7?o}mm=f6vWc(@|^T80~QI%VGW(M_<-SdX)8_D?^ zaD_3;if!AAa+LOr_vuEkT59_+_Gjxjl~P7FK7IoL1fgZe2T)=ri<F#Ogt+g+8?^U|La$rj>n zA^jqb6QgDit}W8gKb6SO0+ureVxy#yR>62?dVo19ORKrgBVSW=N7)PWc*(EyhR4fJ zLtoq<5BT%q>J-f@zJY<~*;=k#GWGYABw z*O$DJMkYMkS!<*^ShEe-F&JC;Z5U^1GpQxQ;d%Y+fYg&b+c)?19_k4OTA2NIdO1fI zi~v$_Cg{-?7O($pjEY5b%oun5Qey%3&cS6C3!Z%JZOsoq;rDw!O7rE?l&(>>vnuOq5cn4=hER3ZhDm(EDPdU$V z*_tKnp3>lrXeBwf;!csbO@?<_FZgR}kl3l|Ud}$nuJ2|I;M?@a(_eE71O*b_sw+&R z>|Jr&c%YRs#Da3=H(@dni0xd-I7<;I^{?#exE&KJZvYIJT1uevYkUecMc3G zv!#)mXc6!~9Jvi{MbWw3ep~a&HJyb>O+%rJOACEm^I9%q@ZB=vj_QtQ^~8NW_4@q& zl%78>7oCSi`yF+KV8>E}JDCf@tBaXFyQ8anlwqIX=HG5*VYIw4rir1JXJ0YA%Xz+R zb*T1YZ3oKkAKA|6;p6cMhH|zF`M%Oa_q5aeX7&l~kse*LNZL?c|7qIN-@fd3H7WS~ z*5FIX*(x#$58^+SFVmT5SI8Pv$59xjNXzcA5 zvguutSyS-`!AQt(R-^9ws7o3`dU z{(i<-VCQZ_leoc)F-T0R-zLApDXKkKWV;WDmHva4V0TLwcPd(4)J`KA@n&P%k@FcW z%e2;-{P|Myp9GIQ(`5}LWw3?+IeuMI^DUEy>lxQtW60}y zmVTl85!as2PP;5fflXjVe>uh+IJRtMEok!?Wv;_jLXjh)UQ@0MgzX~ExC9o2J9r4W za-?|6SxcVOeZ+KC-BYjscDzM3is2WC??`Z{+EDr4k!bev-aj-B)o*3RCXLMLrsyYH z76s=6Ee!sqmDGT4AOmL>_Z6OWO<24gTd*4J} zp@R3ZRWVnp-hY{+*YazY{f@#*5Yub2fr)-c>QH6+z`!K7XIuF3EJpa|?8nE>e6ZZV zd}m?8gUscoj1}~ONJP{}r+f@G<43@F8}?-g8yUuHaa$)`9%2~0MySS)tyO=8ZH%`H48$7mZGToD2E75wg| zewQYR#hGbOxu^&1*`D02fZz4EXp{5z(js=of1L4+_0f4rdTf z-(`Ij!a$&i@5 zx5BNcGsp|`TzAtsi6PJN-3+PWL*8Gdfzec~-1`wAAdrxrYqoaYh|SE@OV0>@f7OX6`S$%t0Asje*l zApJpy8q5)2z3Uhtjt}Rc-_#=%^S2-_3nCw#3dvBs^9u@VvD-sT^{7xdTsWAevsG15*=pprLcO-|=`T^&B(8bh z`ceOf`~TDE-B!S%fRLmwrYQLB6S8~ffD*UkTYw?1%W;nfR#s6QaGk|O<*NC0$%hAz z53DAAezu+` zXSw7i_CHt$#38l*FrvE{Xy;qb>eHNGKRJif!$fKhJG|75?;FlG&~2RrjTwHAvX`rw z_56I(iLcLe6vR!|vH33TEp5;`C6kqH-76!#GmXB@1Z!M<#ozZ~B|S8m^S=oysp@!$U(sj6|74!1wVGeE}P)bbnx4Y6mUDX9=PGgz_$Dn%&n*A64(4tDQZqC~_YR%cR+$v*EPvv! zKV{|*M~Mj9Wv;zdEX(kV8=f|smmi)#HD!1AolOgKu1maj#p^^q^|mWfbTq_^Cx#>X zNEWRbbmUFL;hoL%WHogXO9nvK(v_B9oUh@-j`_y1?<(}}BMs>OP0mu>Uu{A3?!)Sd zpODsiNu5INDb?qO`l2rm42$enuBD#TzrOwH&I9_dEnWI}JXsRX5Y;H_<$h?CJU?(I zApTbf$B=wzH6*A`*gwVjovv|$NEQ5UgbEj<=xg>>=zow??C!CfOwuhVv%fN!i2l*! zq4TjS!6!f^x8)lHR60z+E@N$I7JA*)U$v8HGx znLl_9BuE69eAA)_MdUuflU$Hp056{NO6EHl10MWERs0LLOfkH0`d13Nvrztq4aBux z3cyh5p7Kt&!JX?h&R_1$kZa)Ln|}JO=!oNYL;HuVV4AImj@B}u%f7eAXboqLiI3)# z*Tk584we4VZ215zqH7hWh;*08n+X-Csd6aH@5@|H<{quBkV$hDD}$d+B0?L_I7f&} z*eS&GBwA*l&zw&LxEdP%CdBHzM*ru+a+o1LjsNG7Y5!1(BXu-N>l30@bFcmMeW^Nu zMjkyIMgN=2whVv$DoII5rVrM1fM-6YE#HdVw^bC$b-TwI*SBKNOW&l*1W4~=TsJ}a6E zmF{=wuwx?bzpVz?M+`ET(=QM4B~F7!pDfg2qzWYN0X&2+hu=!R8C7$Jo>mI8Xq)4z z3de6mi&Acf?iRfAUgeP=;TAK0@Sex_!1GO#VcFL6&u1fcaeue$v|n?y?EZ2ZnP=q9 zHvj&oH2?jV|9wLL1ETr=nIv@m4q0Dr<|7+7K5mu)aSp~rUXm4ob%7(-TU8r;8TU4) zt7D@U7}y89b0(OrCr>4Q)}+fVO8_=z`m3rA{IvMPw+B4$D|_XAWe2j(=WVO%9l3q0 zVYs8jer~?kviETe542aRqmqTtb*sCWgPY|;0CR%e@>qPEgODMN_noA4^-oIGZc0L>a3MN!jc&Bve@6ZJ;!>PW9sn* zYe=HWCx_64>~|$!9-cj^6S)5O6y*xv)Wr6&*L+vo=0%r!qnHvhN1JtRY_526fhE-S z(zeFq&$LgjSF%SvQg40!g?*wZ^nMWy&BGH@W&hCp1%@#F9PuBGhq`fg+22g!{Zrf$ z`+s#vG{%2|tF{@x|7xAu8(+n}5P|TuJ(5QKvR{A;$4#Bp2%2pPV5zC9^M73&M79Ok z@k+3J>?1%Ww3!fr@Q{8J0(hYA>m=0i^G&rK&9Qa7}PG3#_0nHuJ1Ba}L!k5oh}|?e%0uYGCiD z*o3Of8(pRDnLJ?TfyWw+-&D9k_N_C<1@X$Rtv(&{FDCH*AFRD;G}PfA|E*FgO7`p( zQrXH{)+FmBN!Bq$_Aw^=SSOWz3n7H;#F(sOAIl&KS*ON2W6LtkASTB2|Nb7`zx%=O zf6jf*{lEhbXD;V^UEk}vKJU-_^%gjTsiY+nd)p`LRW%E$Jj~YZ*WwZ|fJt&jZBX&n z#-)J(^LO9l!k@AT=Lr|__1?#_HS zX+eO1zo5jfVR(@QLTTNqo$XxEVf#q_EdY&{_q6fMHREXxWF(K?W2x7pK!0^ps8ofK zy3%4kF9O=bIVXsx@wSHuoZ< zV!HM3{!wetUm^B5AZT3UMNdAjbp3{m{X?o;v@=x#6J1?@EY%bI9`$@&wL5gy8B#Sx z6nA!M^wVP^50gupx8uX_QRkDwS?SgjvT527cKylH(LJ&}Yf$R?7C6jEBFS3zQB_LC zDF^L%Q31q&1Jw-(C-G}A$ ziJ-UTPs=SsY-p#5LrCICygE;y|7WBVfLd;vKs!YdU*tMvd#hD>RqRv~Bf6fet!58N z4pi?Wk2j1(aqCQM;Bto?M?K$a!q~ue&ixr_#FuZe^^Up-XGH~%}BsO z1a63>x-&l-jg=L#Bn<63f5=tD{*;=rEzuD0cGK>8rP7|BNZC~wR76;?w&uy9Ov#ixC<{e$LeoCxpDFF=4tsg?1@mV zlHb^idrfGi>llg>v18nNIiU@`WPIlOHTB!mFISh(`|y2q5*CfYo+F1ME8MbsJDMwL zCb-}`S)L8;Y}3aD*V6B2^MYfX;|%6KIMMt)dI`7n+}%r#7`)@u$-i!9a~qh&z(epV z+P71ky~;y1EU%J1&JVD@3*@f$G1oS6*<_K6-hn%xZq3@R+|&xF&XRs1t+yiPz21OK zoWBGQpMv7lv;pG6jFwbGvO95{Nvj# zb&aFDw2+<_zkrI*8=1o$H$-Gp*ALeuvZ3S%u=h^q&d=A=+-zKtgikjrE5g4YDjiSN z!PsL%^~(_Y;MUY53yOc${_3Ol482)RP$!;495vX_Hd@5_*FN{)QcHs4fyLe!_tY$! zKU~0b!u|WSjq-f%$5L|mzV}gH``>NOPz2R3i)uGd>`l*0mu)3kUzrG1C^v+v{7N%C zYqJk3;dCy2+jrmG>7v*3PC=(@>DXQk9S{s;L}`pBqQrXb#A$Crh({qsP6J+jf&tm! zjq*kRkl2UHo418o^QFC>C_q*kj?@uIyQv0YpT(z>nK(;vub#o~n2R5^dsSD>lAW;|gA(B_zKX|+jY*7~D!ulcp&V4pz9D39 zz+reh>8xc>uz&22&FZIuYhHG4&*619^V}#q#3H@)n1u9!mv1J*Pth%?u8D1iXg9{S z+Ps~yVZz+jIF5B;2;6hqXn435EEsmbPN5VRwsw^n^!l@zP;=ycWOH9>kgC z`3&ul?yLllB&(lupmOOoj4R!&e*Sv;Y0V@D%Gx}k5p!oougoW|&`@ zl}<3k6-OAx6d|y%vC@RI6){NHhY2%ahoZ-rn?-`6F3BU80Ln9=_s6 z9@A5MP_EF{bY5ep@@O=>6crv4$`E1XhU=QS8_7ZzY4GVuGTOi38(l8-T!#Y94g}F0 zF=|XBmgMw1ls&lGh%xgyn{=c9Z2ZU5!b~Sy;K{cbvMrS8o^ov`s&b*^B-7G`TR*Ds zSEK#X+u|KHWKDkj5fP?Y7t>sEHeL--?;wfEs5gAkg@(E31rall z{Cl(EKLz^ZiIjG9BrGX0EQ|;i<+7jAdgGtWt9j4G7ezW5TvSC|Yv;tvff%jJmeZhV@M_;L=$=~Vd;ppu_CUYY) zP?mI3DA>KSw8Kw2~20zNQQB=KHE$y{1TS@3$@#Gp5m%FC5U3$F4&c&eYb}!nr?1pG4 z0pi*K3h02EA9tW;-!1j3K`)+-`48e9(U& zSkTP*pmS+jFHm>6>0S?$w~f{%P0rrMWDd8EEA>>_W}-}p(QDF}jE;_S?^5H(zOn8# zU*>^^?8`YXpDcXpVf#4w7$SBdEpujx@YlNd2wJp}uIU_P5|BApm_|2422_@%BJB4g zfVLA{q8!mv1i+p6C0h1!K<}&v1~(*5_qs~Ft>nm1bEJIJX@bvu{kksXT>Dm$!A<+S zh>Uqh19cy2(ZzyIlH}}dAojqNZfg00F_xuzE+b$MM-3Q$wR+|@4mBXB<|jaG2m5{q zaO=R*bhT=?mE1FXbj)}k?Eii|ELX~9ywWPWdaLuaqi=GX)vk5ok^JwBfPGH4JTU+n z^=I&cnb`!nfvgI$XiX;XD&j4cU0xg5@diBQS#(ry`l^=c|M=tZ5uk0KTAn=k*+2oK z*HDZQFlIhjt9BYH1E;DZBBb$F_5yPfgYHLdv6sPwh-+sCZ#+NcDG|3d-CWbOYct=M z9QOo)B)KL?qv(>NQS@___74v|_c8Kz9#a7Y;_s`v{Xy;zf+7r)o^PIcaIvF{f+1Su zPyolfj3Qa**`%PwBY;+XBXa&cJKGbST1!WlksS?*iO5p(1>Q!8!4thVN%_f~b4y=Y-z5tA@Qll6hn-aIIhdQfRM^kk`iq z)l)-iH>EVVFx4YjXiFD#hy+Zvy>S)GkB~wIGms zStzmk=2V?34@=F57M*$m4{FFRepcBJ_#e>cZ&qXL^Krs*-5s}^a`cnIE#qghuIEsMEU&nfG;KU!-JmvVHosdkD+l#4c27ouy6pwEFW))2%BFQIJPo zjkY#38TyH+;`^vev7>gHC16R(>(yGFB4GY(R9mZBK6+d}b;ppYG1o;m z5r%S1IO8vv^{(}z%F8BAsd*5iWtuS?hzAwFvzfyDfkJmd+px8g`cT6~m_AXot39x{ zL%F$j3hpPYx@y`E;~XGg{h(L1A>=(ET~&6%QexO-D3aw=^c3S-=wq7u!cBF35^#I0 z4EwAU8I@M(9$=~b8gJUxlytTrZ*Ap+F=zc?i+P;bTSu;y^BS2rJCX5F2=N%CGbJO2 z9rKq8l944l5C3xXzqRiD^R+(DN+Y%|H-W){rC?*)TuJkn-F(44a94Ph-ThSeIZ$+* zfh*?q&4jN5{9Hymh9A0SQ+tye0_w-Nrq+Vmr4Qj{1eA0KvZMn9M#<*3OSx-ZYI9w% zkGVv1C7qs1B%M{dz`1K(I}s{X2q6se*CQ+t0zXFlb`@lBo{55IB&-8CpS`ET`-2)} zj%=4oxylfhLze62*QY`>pVnosRly}nK5s^*4mI2F003{l#c zFx`L&=N*&F7{+Lbs-HTr@QT`8Db;wAIX0oH9tJn1Y-Y=prl>@3$}S2d5E2r(J@<*# z68F*dmlx&lob3hine(HMj6xrdgaQ9(eEXiJ&R@qxKZ|o{Z@Ykz}GdNwpAukPuLAjqRl+S#R z{OR2q*;Chw&0N9?&hC+y9{d8g0_;gCcRbdmZMrwVJ~OU^T`SoSeYvX`5e**uG`gi2 zNZHQ`JZ~$kw*oRKNM$jnypM zC|+n{yjo^+8#oAN!@A-s`zo5VUrbhc2_HB zhYL~hN#R#ReQEYpYc2cvMKlmjgeA>3rw7l=HQcG#C^7LBV~}`b_e<)B4&Ax)%JgJ? zD!wyBRFL-h$Tk@VM2;+Fmjdkum4x4QT|&X>pB1Z8zK%WEd1yKEfhB^3oDcU0mPk3dM`rzHL(Kd3 zo=IDGL;Dav_0~Q-O3p}}xH}^p;(DC32ZEgHp0d$wMzG4KLmd+<{p@E zRefpW>|6D+F!*pXriZN{lB}>G8(lW-uIW|ud0jOOYVx5Q_l?U{3mrVN>p{#23pYJ1?W zXhX#}44U~ovu#9GQ0xY|q=F|6fo?;z9D{em}Q zrxx^tnr<12eY1U@RCbSSVlKFE`KJ+^{Crdet~JCk>W^Y4NZEDJUVMTKq7m?(ygNDO z6ZP?}P83D~T4S$Kg%kXJN)=W$;Pcx^I$=8h8{p-1eYr}cRZs;-lw|8wkcHF z*m+<;74AFM15*9vw(&VR0oLT=V{g3N%ae z=E=RGr-C0`Tdid;^ydmCdUMC#lArn3>?fBXJRP-O-rzKM7#u{$F02)Lc}UIbz=P+q zr&lq5_vD>uWHh=bB>xhtu?o3{_K%6dVJey~nYnLI?kd~vqyOzNo&89jAD#(qus5c4 zEwU!ElO$vRB>HxRb5n-jRCox;BoImfA$7yCd8e>B&%XYK)N8J<&n}29dd4>0n4H8q z=dxN%UC*MMlH$WIwCS+K6;Wo}XILFBmI&hl&zNf(W~DFbxx_BJ?Ml@ae$g`~n=Qyj zR?sBDl==OrN+@p`3L9f_cL`1PEmjU@D6xBwkX9Z4iw~^dUx!Zzva`dGiD$6 z#DN^VvHhL7Ihd#G;{*KRpT8eP(y3zoa~!nXa+-a=YUZ1*`by;a8APnok(%{hc}h{1 zm{YDq(??waDPDJ#k5Ru{FK)?oi`K6uz?Dom-QA(C)bm@S=OKt>y-a;dW5p-^5b2BY z`-sG8BaHXJ3hrr0z?FYYep?SbY3vJnb<|ThhRh`6N-wFabiIQ=tvit~9=*3RQSFdn zB5!f7vTV*Q&8y0rp=pWN^Lwz_C z?Z}iCON0*TKcQMi(JjR-wOm~(j7;xRjrVbJP7N&$O?mE)xtDjUOW;P9}>x91HpWR8JG0U-~DN9YUCu5)7kj3Q|*pwzxl#hDC zZ;dHI?fEyDb~Ii;3Xpwd^my^>?+&q0i8|UF zph$~TokJ7tIo(Q4UsSu9oNY?F_e}antqJ#+0YUNu3xl6$9y&xlasM_{Ha<8`KiJCr zp#KmL_Gh$)%fY0*Q)zh8+avEiHKU&116!xT>jYNL5Vq%DSzTJ33Wh>3KwXv>gT+&LwO3eD2 zyXg$4nCnyK_{?vYW~VAB9I_1;)U3zb{7LOBy#f2Ir4U+DA4sx!g4|Od;pLb|00mtA z^~?I|gPdPaKG&E5Q97?Rg(;|mw?IB@dy>gToiHI0r*0jegQR3{#m#TlOMwrTBSaN2 z!nfOzsQsOW`f*BYu-(Ss&eZX|a%Gt2;!x=6BO@r^1tI|G(=Gbx?mN9PC6KtB;8S?L zqGU#XU^&w_rsTC$tod2#4%5@aQkiYX73whm>(K?e(2LO|>rgE?NyEshjlGH%C+Odr zYG3>7gS5ThQ|4IAkoX7bCWB${;2&Y%eQQc!OdHeOC2Wpa4hVk97LhQps=x9V&owAb zSsm<8-SAeF@J@+K{>@*8J7fS8a?-+{c0O=7Ovd*+IUIrH3pGHOw+kfUjp?L;p`P0w(!l^#}TFLl>_x#3_Kntefbkg^0{ z^_Rnjz*e#BjGNG4OjNiq&7`x%GE!Xd<9emhH4 zMzGwwNp*kZNac4%3rl2GeOQPcWgksUd714Rp zTaZaWB^2M=S!xLnp8nkI4-HWZ{SVxl8oiL!xhHqIwa9^zvtvA+p#Zx za6J>yUGA+eSONF?UuqnG@o~oZ<>=5CaKFMGCl}_3#^wt-Ys4O3FC2bgmy9#@ z$o9(^l1U%?iHhYA|6$0l_ewrJMKsg#?A;!^){zPg*|Wc_)u=S97f4kPm(AW{tlyK! z{_AKpOcG-P9K765PNqKay#M)@D7S5kk5;zdH1h3{oD51ylkc+sQ~Tg-%%bQ90J0Kl z3nOWrSkt_$$y+-P|6}|D#~ZAb`ME9jyuR}BNb|kZ54L{$zP>Z8pSOV(Rd=Rzul9xu zQjI$zNpd_k)ky@Nt#p`jjx&OgK5nfoL{-PIvuCP%TBpxk{(9xiUlZt&PN>R6xa9A- zOXKy6ftTv3N=ZfDfdK4sm!QkLyIORo%gHg?OHkwdm#^go$JuRaL4~x>cwBa4^Wp_F zT%sqQuwUWLt+KK2C@^tV2N$qq<;JqTqDx!Wnl}-Jt}SOq$LrVbJTqmv1hu zz>wFxi|xFKf|=bhN)~WD^C8Nu-5W6-oHdlT?4-;Pfv>xB9w}XLpQ#z)NCCkO;}2cZ z68wwFW)yfAtxbh=&YukJ71&N{n6GKfJq`H0PZu_z>g3x}^fDSLdZ%iyy4-*?-jIo8 z@==9vzFB~&bcSD_T;R>!-ukMLmlwZ|`t~b9*CfZw za-fuW6T0a}>-)_#(Dq-gA*Gd)u_?QdML9xR-@fX0tRjj9bmUS*yo@6MVHJd*f@arl zs75rzk-!L9l!~rnQ#yx@y+-X(9QldUA1!A3eJVOhQx=i)=;4@R@;O8pnfV9A>ic2( zKvQj!gUI&1r(A+qY14yK7>{?h^LPex4-gz}LFJ8Jl;cL+3Ukvvu5te~1YPpDAh2nD z*#s`7E17YU|BBnk&!vw|4K~!Z7QjGKN)GOjT!oj1(4r8MErz*|+(z%eE5FZu>HH^2 z@!h=Kn`L!ShA4Vq53g-QkSwFCEVj?*9)aHij%IbFKQ){EBu#^^R%(Nd9RHctWXCE#!hv-!6`zS0bFli&0U2 zv$@kI(E*pqzV=P<-Q|;y<%6+8p~Co32kQPbZeMjDLu3WP*>Y7ipgP$N# za*x)gpP$YadtC%Q^>Hsxkj6qxK0i%U7CeJJM~vCFU|01PgA4>{jkz@YwC3qMj`5!M z8SW?4CLSjrG|ba|FonyQ>g369bE74V`GL2vBRPNcKhWg%-%t4?6EuZ?JVqBHFgIX;CsN^Y{_$`Y zey;oD{;cs1P=`~~?ai;GEq;%cC%$4=P=E8hhxk!*C5OV-b7&V&*#^b!K7}Gr*$I-B z@~j5GF&_SKbQJ5kfVmj{r0i-?pZ3(ZHNRsy^zdBE=Dx#}$<$n9z+byrzS7^TNTtTkTu7pBC&c!_C)~ zB`vE?)fJa!7OdMurxn{Zd}?*7&2jQobs${9-P3hud%`l)`hNaLIEy_(!EQA30X4j~ zgZGVHJKoDLq%x-aX0EJZV?sY?Yqx8Q0#EKzSL{j3ZMA<)A_&bvl4Jy3hX^sUUKsx~ zq`7F^oI08AY_O78Z%A4QHhqrR8aYEZ#Chxk4w^G?p?Ye9}LEwlw@jGkS(?|NbKS+Xh(Ogxq& zu_r6CN6twm0W?0r9!n7^s0{nFXDyAnFeSkdsA9TkV0PoEt)T$R@L98j&)J7&z4>Q6 zLk;1?%o=Xi={=ag_93bw#KwNT%!tSfV`iP{g0oCe&mV^4OrRfAL zd17q)=gM1~lFOdQvmxSDKxPSCBLupXoc8Bf6r76hL9ZvK1eC3f{* zSaY%tBO|xY?k{mZf*}IfY%Zqufys=^d{Hdf&H=3%n=T}1lvHNQOnqd8eb&!KAvWMp zF=Tg3uPn4pJH$mM|E87IFTxK{^w&z5v^jOI;aa;!Rph{2OdQ z^Rt{kMhs(_ZGUr(b$@R2kMGBJ9&br4&9;jliK^2o0TanVgu2<$Jp#m}%ywP#Zc`2B zgDjiPGiqa9JNp5j98>?J=O-otfeLn;vsT}BoCwpTSK2`y7sY6H{gzR_lzB(mSHFI7 zMd%AZcJ*`jx9yt3S!kYgAN#n@Il0)V<#`5D|r1sAO~v7W?Y@GgFoOWX<&#E0W1QAO? zcQ6NO_2`|1w2axJ<@PZ~!#Lgx4LewgnSboM0l@p$u{&PzVXdy#h*pwqK5sqxlrX4! zryp8C*s$q9A1C{izC18{_@j%3F!J7-<0V-<=X}krqt|HB1<|;cl?oy@T9cD_M@sc->W7f`G9qO8VQO#G5Cpz!Hi%6`*6jHRha``&>2*Pvu{z%1a zt!Nqg9j2C)%Su(!{E+=m-`^$~bpB&o8$TpW+_<%XZ>3NpPV0R9}#Po&g~w-W> zcYMy`3nd-5k1iufMV(ag_L8+xoSPO)4bbg=bvuT;tk8B2d1!`A2znYYHq~r~&&fn# z_ugaAL8ehtxuS0nT8kwIJy@Xdb)GI!AfB=jyj)!tei%&`Q%#QH1R8=ipj@sFv5?%= zL~YqP>>csk@?&5oY9NmKK>!f^{2IYHGg=+?A1`qrKvmP}cs-^j$X^sqHeK6Q=i_g3 zUy$PZT6%j6WcUS6c0SJW1BbD6X9TOnsB064z(6~u8$<;s;5oJO=UQ&O30u6h9CJ-G zA)xKiiNwne@0GxK%~M^dUoGh#*lb;Q7xE zTj!JSnT@pqZh}@1mDWn|5I-So>oh}j`}d*yulZO7`m^Z$42oS&IRbm1F$`y&oAnR~ z%S-h8V*6nW#W8!z{!;2~@0u+8Z)bEhUC2G6+O#y%h)}2|&6|`g;I4UbVzbSVWUMSD zEbT7;l@Rp3cqY~P#|g>eF4p4l;iETy zy)FwH{!vzTtpegBJBA-8_^KhNTjcU91wBH6NqvKo~={m7o*gw%S>XOZ|k?JK6Xe)oDvq>0K)d2o(#am|V9cklEE zAtU|Uf3IqBAUAC%kbBuiq)-;hxLF&mmwJliKN3|;X)G(Z>MveX4o;tWYOq;e2pwSN z1PPpLt9;0G2bS^K>ZTgBb&Mf6pw$(EJPDuzJ0@3h9tda_RjMykcKSsYfrwv5m+n`?p=G;-Qfp1OUuy*yEo;)?6&M)UDdL~po zzT1vF-iVTyRW+Y8dkdQ5*83wxW|NF6$YYma!o1&>?b7SyXclXwvUCoy;4%s3lS6Zb zsX&#fgEDdLrq~Hobp}t$^bOxDm#&Z9J_Ejm=+C1Y(_mpli0^$Y<#9Y!0A_)In5>)s zjN-o^%?TlN`?B#tD-6yoOZ)_}IlMQZIpj6~v6atF*OsdFGI3Jc)VkOIK0@N3JqEo#1;Lerg&4 z1&=md0QCao;qnH zLd5?N4*kW`s~Ro;{PMwekgJCYc`OS#XyG0r^4IWmcgKzKJq^u&OlA`cEsbsCt}T40 z9Xfy$FJ$KG__=P5p0}a7ttK!MmSAE;m>=wV3$!@bR11+>+q3lXbGiJ9*U!c^uuH_=~q?fY6(N5uhpV3Nm~PU5>rXTSQK=;#0hY59u zN<_R4H8W0~N3;GK<7fR3mxdjsGl>Dx36hxf!v8dpzEgZ`ZYFu7_{Mr6y<@c$Js6&A z5jSPAS*AIwA-ngdA>&vy+!d_dB*5N(7Pd(){_=$y9lbnN?RQ|@VN9-Xq}EpzZ4HPA z8dRH@dFA%FU}M#lilO-pa&NZ&$lMxy(mJj#S3dJ|#NR8my|{F1h%H6oq-kTF>~dF^ zSi66V${X}FNe@LDvIB(;0_LGKnAax57(n03TMNgbw_PqHmNRhMnUEdeg!S51Ab^VFI2KsK1@EVd0%~&2y`{ui7 zxs>;5`~JKK%YT3MMIR)g7{sn2+>?GWREH> zSSvDEKLYhf#t}HmPw+h(Z*aRFV^6S+&uHQl`x~OU+j8m2>x6IG1K&k%9Op{6>3l{+ zflT;;yJNOitdL=~n~g^AXM)E?(-$f6175qGPKoB1rw4fhwl4cv?S?3?ealCD(?VYF z0L^*~PI{3?`ELv>gb9KF`T_unG#UHcef=!DAZ02A9(1Gx9d)<3vymxsCd9Bo5u@!Z zebd!rfD4q^%UyS=c!5{4Jb>%=@v_SrV<5`;8-;aBH;n;8RHgmlLC!y> z2XP%L6Kz^0GxVDi;R=6Ws^H2)5i9e!5HoU$HV}xO*T^cz@>RLB+d^Ft{}VT^L2{)h84lk zU6URAu(Z*}SSTyfL=S7<3K`S0BqtKhNbq0d{XV%+>oGIXk3TXx17mVFKOnW%#aTB3NRGjMIp=2g09&-RWjnQ=Hdr@PnVetIf6x)qAU5=Mm#fPc(Y> z`SZPA1>8R-HnL4hFUOpbin#qZ7zm)UO5aN&@gGJ7Vm`CqnUw)a?t&4{Hr1-}X7hQzQle&eL5 zttqK;=Z1IF5gj~DmY#awYCR#HmGYu?KxK;kOfoTyU7He}&}O;-GM4c=k*1$LMFLIb z;!+1*!eLv?sfs!I-#f#`w6yN<6l0m`N^lC?jOOZs39u8H@H=YPBsn$7LofuLzq|ZS zY&Nd$kijpWb)4F3x`-nK^Mt^V;EiJ_dj0p^@ruVLPC%~3Tq~>;@K=kG0;A|BBs;^j zdhHY`qS173?}Fc^!kY8eW2kK>_q@APcdyA;5S-EmZP}bsAec^Mq_WoO4jwl4;lco8&IU)}k0N@YJj$dXh0 zsCc-uOdWF&Q631m9Dh7O9+Bi2*K$!04is#AgmD?EhMpW=SI^sB1HJloS$@f1Fha^$E<9N>$%o}*Tx#85>NXNs-g6T#f|M|A4u0u7yBqw zTo4aRdKUYSDfq)<1k%HFLl8-x&y+%r3tE_lZPUO&QANLMtMwn#C*b;efU~FT3If~F z@2gtJQ~-g;VP_8LOxD2Tg#$tz3nHhR@6aMvCB~xPGHGb3y)Hi1h8NSEq46!?WUA`z zU9JS7C^VTJu9;Oh1YJP#%y|Uwej_Ny3k4VkBB26e?F;ihH9coJuN)uY{yGcFe>-;^ zE)(v4Xua>qz%u`b()wrSPZ%D#cPXDiiwCrxBLDegX5fI|aqS-yNoo-33jFL4;MZyi z&aK}J@@f>h-aL+~YG8GG9ddUu>g4m6ZFel)H9fz0eg58RY(E~Zq>ZL^Z|IMz-y{h` zjcO+ZjoPV7g*J(*gf*=xy};qRCcW;uey+ksZO=-wR_2i8j=R>RES~2eEXQ7va4ede ztncA$-yz;A^!2G3xXvMg;JI9T zX&Pd+0ksz>qRhvm6mu=R7!qGbxBgtN|DbLvZBb?tl65yRAa9Crp z*^wqte&)SSrQ4xfH^a(zL)+*3=j>xZvrpkg-hV3@A{%t}!v9_`iU0NN&`BK^=q`Y1 zkgp5V0`JXI_a?Qr2ZJHVPLi;^!KNbBTOFpw|5FXQAv$_x5)ajA?( z07{q__CZ59KXVbe6T&Dzfc@8S{@+t+$(}!aT;%Tf9yU67LI&yq@R}=sA?Es~W)xMP zxNk4bf=)OGaXf=JnP2!Q+xKEX17OL>CD`o80ynMxD7r)tz}^->MfS(Y^4%&R5MD>q(Px2-L*p!I9v>#?HiNyoz$1| z=Q;;O0wu}rB{tesgCPrA0#?ZvXZxWfQ>BsuMmKv+s&(8qmsFZ=VKFe`QW? z+r*-p>0dEVPW>o(y;H z?C97k#|EgNctDq{Wv*+=lS|(n;_iPsj5?J|OQ)`bE{BV`*$IvMS16&`*7*2T1eRV- zhedVkIC{yT!Hw+)& z0-^sLPUDS7g8%0tYi^{v6sw<*i(!xSxAIqP2`xGA zUYZPb+SdN92-_sxxRh4co2NugMY>u2Hq|aE9%4@uO4VqNGa$wdsU)KOZ)D1T#?@U* zhdH%^so1|-sW+iK68Jq!&BXsXj<$@A{q!H3bd`$*a?x!MTPmUXl*A$8DD2#X%%4+S zM(>)qf*z_eOI|`3_c6$!r%9RbswEfWa_x4JK+^>81 zRNFF8)%elRw>8R!tgqZ4|Qup*3fB&ntf&vwRl`f5-D&CVV(t6-!H@ z1nN>{%lsu~hxYJ^tHxe3Gkq9eafc#8tAXx)?I(!UkE5g5jl+LTlE2~(f!qlDsft6z zeb=J@%LZys93ilRs50%tHGI+j8z!f8P7jSCrpPjBpu8KW_)_kFoMFByRM~c`n(vl@ zv-E#z$U%+NGuVQ)mdrP8roiNw}Z16*qqCkofJH zVh#ga{rx{H8h8N)+Vz+IUj`Fc66%q((Jz3EYOrUaW%jU`hs5mVnpni)H(TCxm;8F7 ztDcJMQ*!4v5qkI;1LV2@x=DkH0A2it63SzPoSi%N-6em}-~g7VD|+h{%XyYl4JPp0 z8!y`b=M3$5Io=^b`-bmqkyh>y3$yIgyi2t!C~~(H=uL06aier8{R*^NFnAO%++K94 zR!za+8Q=6rDBWs2Gz3uk4ifw!Dx|!+s&&Ba%9FU#T(Xu#2qnLaaZY^R7%6P@)hAWz zc=UDKlGmsYnPG|fQ*!vb$5m1h4Uxr9HLy#}w5zZ)e$#rZv#+){Xwp-I9q` zQbwB^S#*812a_XV$zd*3=>**ogh>|aXeZ-Lu!`?IgWe6eK?xJ&E zGs=o?IH@jHXyw6YBb$)zl~XpPv{s)^UT*79m~L`OMHFAV%Y5R=MRNLkfLsY2CyKMc zVf%~@L*Rh?^I-OW?vQVQocZ$yB)JH}f#vJDX$AlzjMlJYwWtBiI8q9J@PF8Q&!DEiCwvsUG?CtkQbf8)6)85lh=|exM5Pl$ zqz4FzqVy&pARr(}5h;=0iF5%GsgW8IiWGqa1Of^1e!h3+&i(lUXYR~>`G4mO4xht0 zXZP&xv->f&B-C{1cMdyq9(3pybN>ZFsAmL4^xi4 z{jjnj#QIv_rBhB=*J++P-R}#-KEgp(HG> zQ*`_>(5z;J>|NcbV~+p^jhh;HaQKyZ42qkPsHgN3(2Vin8rxY(#jcerYD)GhAp>rr zrpFyy6xi11wyd-}{T_dDm}!hvGoc(fkeRQO7E2#J0$@)T*ZqQOC#Oe689@vUFY3KW zulk1l#jFwmO?8Leyl2q3RRuHEJ@<@hg+(lF<=w1vmI-;&kT>2 zA@wNr4x~9N5&(}xm#j`;BkYMlSyV6Niiq|3_^*!6iMI?kn}x^kIxIS^8tp^&m+7t= z<9`^yn8AZ?`2R#JTps_M!31#OtHwfMgr$uF(r(22F`Zf+f?#$#+U0q*V9*w)vCy64 zU82HAxf2{6MekpIHf8Y*L#9%-H`jZ+34a)(o301tPu%n^om}?(Q9mSLKpQ+>vTIcF zm2**Xl0V{vDY@WhK4zFSu0^#j46e3ohpdj_v%)|AwS>QuD3>B6YREoVfQ{MJ=4br6 zik7sTuNaBuw>tmB$dkJx)>m2j*sE5D<6r!=ITutyNxXBZvf zWOS4641x!Tbnc1Ol;pf8@ zJXQD6*$RvQi0$U~LEX&EpyL!9VlNvPWy6}xl?p3#%bn>|k$NAcw`E8A4%X+o_Ho1C zc2T9zV9o~zsRF z`V{jP{OANE!+7*Z5d6Rf=%I%Xic}wZbG#s|D2WN0(eSpl*pRQ*F|#2!-ekll^N}bx z!+O;0vjbP6UhDfrH4FWR54x{>5lZdEL}_53wMZd#hJMMa$a&$cYeqHuc@0B&v({Zx zPR4WG{*rKlRcdNeuP{eE`9!naPr4-pk%GlJjMe^6;{R9Pq&7@fQ;0yazNaL$XEBlW zBSCl5p#jR#>TBm4%M*DKKaF2_dU8qkh$=fW3W_5e!wmY9c2WlAl?P6n<{|t$0=NG# zL~;E3v8-zGUEf!zrmV=DWd=FeE!frhT$YOV`{9s0<@*VsMg7MbvQTQMpD0}~ZcYhlA^JkD?^6B!?mrMpd3bejI0Nt^^85ZLO6oQK^SDWBYKLuwQaxqK zC!Vc(r+(Iydr9Lp?$h$VU#L%txxIT*Z+?PYfAdI!kEN_*!cl3ZD-Q%eetSXAmGi)L zeCi`8aJZo)zCVtF#xqarKH1&uz63u{bS(_>w`MZPdHLgBY*PWNFb#{r+SQ(!LF1-> zmM{GyCkv(goLgAyZqF9f&dggCISue7*|!f2Z`Z*b)78r^To?NIy+@Nn>_f7b&?~d& zOxG~?D9LljW4o;EFT;+k z)^6i~gp_&-4_Ph5H&AijGlv?8QY~fPiR>3kuq5e34tMyE-CeQdQ7?Z~IH@^w%PGsu z$D!@briGf{d7P_5oxDXf`M;_RFTt=>l&l|Djo~o%n{ZB>x9Tpk)#r+Fa%sN~6Eb5h zZl3mHd6<&WHAiQg{RL>m^@QNIXbO8G?T31c0M&f5{j!SNV(pOT1CdHNAwUI9xw#mB z=f32?=H|NNsc-l49(f$sWfb$Aa0i88K?M|GitMriI|Y81)CEL@K~!IXxnFq4I)jd* z&M&(?Wlr?BT`nesWM>?I96{}=21Xx%n0{Vjm&r6a?+*he6rhXxvVCx9FmMWZzG6%0 zViYxFvT7#G7gp$QmbRu#{MaKd5n=eWq#gR!A)^hw}lKLqbI}vqB`sA9Z zEMBr?h^9+EpUo&*)i0vN50cS{Io7rE&1(nEn?0Ma(5X9FZ?;>r3h&Ir{?12z+4Gp{efu!%Uu}@3omSWF8{-GOit7-GC5P+71 zMGnH7t`dR{IIrWHbU#v}*FX-W?U=KjsIci8yMP&Fc{^_+MNjVHiKtMyIv2yvwmS+w zKz+r=SM;)ZRN`vU3i$Bq*K0R)RSLk(Xt8S%ev^C@GPxQ{_|T-IQeybQ&RzQX#OaZ~ z1roEfo^QD?zs`sKXmOh$jc^o4L*jjv=0HIZQZ3IH#ENxO%$YNy$JU&m06DC6{jFcVkeL`^h_-G#G}M{obw{y*l)V;UjcZY5cGUs7!DU8&R#pL=zy#mFLdXWPTi9 zPBMW82zh?_{)KnpX<5n3xH>1X5rMgFE#ppaCxa($jAW^o(pYO=t>H9mi2$|J)(f({64)A#CdTfS4!{xh+6&4GWa<{Kae& zUCBNXmxG{QosX#amhhr~gty*QBLvoxbm}uGLRs>oX8--L!P2*}}vS^)M{ zwU7ICfq&#nQz2sjUuWcKe47+TEf4c-I<~{&#TM^q zZwzl1U;W=BxeHV^&R<3E&q>l>CT}^vC(Cr3QT#TDRrV5zI&2e&Ps_*JPLAPzdASvR zK)Yy`Dn<{0Vc|(cfcs}HCX zkc$Zp&;2!@JKQ_&00E%D#Ny^guR+tY%_Zxdb4>MD z)bBF9Po6Q}vw;Ky3_#@Vism8UADGqQ@4tl)+a0Oj_hbJ5vMulIvn+U0d|`&C_#cL4 z!&rizwmyA{d)QKi%uLq>hWS^Qb$*|E$95$*>f*666fgQqvBE*9ug-x&U2c3Rep9*+ zXvMMvP#)v;iv_zJKT?U=v?LG!vi=a+`onNb%anX%8VUL+O0lCsg$DOHm)NW8)IQZS zNFba7QjZ2uZr;Fn!qSc zCAD_!346Ib4TX>kb+xaonxK*s@;owbjycN-%XxFNn1hu0sl2coSEw7Eu z4~ddd#W|)9z8A8bc$j@Uvs>PNp z%X(5bOG%<^%^>@mH`7pWqEq=_aBy+H-AHA_I`xd^@K zwRc$-i!s0)R3it}C}Oh^4#b6l&PqhV&)an)+xWA#&~t7VZn7x$2uYVajc}Ov$uxpg zk3I5-*`q&0&NO+F)>rS7^7FCbzB=K%`_((S>2D`OJ2Wy&)Vz8;WkJqomAyWtSRd{C zDcRfiS^@=k{oh4!{e4PFMslu+7Mq3&gXnfu72hB&4}3o!(wPm=an54mACc66QHYfR z!Wc;{T>c45;6XlsdQPa-)F|nzRLMDi5AeKlsRMJa?&*#4)6CD_j^$J3D1}z!=9c21 z0)*OKY!&&X{drRXcT3LcRj(hVOKDHUEMFY31Gc(|6HK9$`H$g<(jY)Gv}H{ z-%+nBPIBcN6+7wiJ)CEW_tpoxN_29_-5)2A zpAMbK?2p;Mz009o%UC1h7W!4sCEz|!XxPr}gfNlO!|xN9L~rRZZH;aW*IxgryRZ+T z;kO++kUG?>I^i3d9F_JHtA0CdCDVuy;))WdyPC!`Ii|54S{Z;4zs_OC)GWjs)0A9B^R`;x-IxtK2BVWj$TD~D$}W}KV7~PQId!Y)UH?p zr-NJ*WL)H2^=KjW=A~(_>+RBy?tm8oeH@HvT|6I>9f!P5z{gqe>;?v!%oXYl+gOV; z^C+(dIsANd2A{>FBfs%FZtvJ6>VNMF$YBKiGZu3IG_BBqm^QT*OxMKHqNMlnz4&N! zv9>v7`oG&r3@e>0SmtL?q7^kU&|?hkz;u6C`vMBRrCtvkNz zdbf`%B1K!ZR8j`G{8i64xw(FFqvj!Rwci0h>EA@YM@TMAZ&JzjjeqUK-)7Gdm8s*r zQ;o%}_OJS>_q4e~eUPO=RIyb*EDM~@@0O||jDPN^I?)+gRwZ7~Bammw>@CsQ;qZsS zs3PluZ0oe$u`egLr8!af9sl|!3Yg4!t%3s33KCGoL{^C^s+*>0fn#!Si@>6p+mp#- zmkCEzwH@l_kNmd$+b#Zea26G>^X4~-=~x*B-KmIt!2mQQtEKc!gT|^x_{%vHVE*>b zSpIW>$yuAcUOMOSO0}1K>#O36B!O+QQ~cVBIW#tM{Ie>Zmknjl<-?08m8~uOK+5d= zhvCkMd_iq_OW3X2Ef#+%=BC>f6J5PsJiyRtsQBfBV~6h(SJ^Pk4NZ~ROuAMTFKJ4F zZ<>yf8g)Xn#hXTDHT6H*E_*_EQJ2Fz_Df4khP0}i6s-i+*JcymTO^0bIyQ<2MS6$w z*=)322JRS9@|k>3o3%O4N3s{hUi7-6E56kij#GYHyP{5!ZflFdn2ceZ?M%yVu20G)tZVCP(}_4cHq42p{@3jrZ%fW?qm? zyKtbB>vR^xOA4BP_RVjp-^>sWx82H2Juq6f^ND@`JNB-X4F6A=?Of^)*f!U#D7-|4 znCgL3*f>BJrI8x z{YR7D^*g(G9JZ`kj&g3?8itwLwU5KkBTGr`J9cEXFwI~RwL_yOY4(RNh_&DQpp+O5 z&4`}%?r=6Xe8f56DSLX!zoI-eFZtRNndcnELD{Y7<9Os1ic2oV|6a;W(B%1xiN+Sr zG3#kl4=ak6+G}mrS`KwVVSA;!;=;zcau>*=V(w8mZP& zg`9QWy=&8>G;J3fBYsN>+z6se!M@YJ;T%@T^+Ub_@v z;0()4M-{m^sphyHS8}&;>U@=%TWv7!`tnQIEtb<9@(&t9bOYo6;eGDe=%?mS?lmfW zhg*AZS3o4tTk32AG@TKoA@J9BN5TrKjoDaxK4?vORWjO|K6z{^d*U3S{)$*!+Olm~ z>TG-jp2=f-P3dzAiU+5uP=vR2Pl00G{Z*Bgm8J}arY`&_E#Ot;&&)S#;1L|Ztj8B* z`j6F^!QeoZDlps_Nq1o<4G@qZHrmk{k*vLHXk(8DM#V(GZ_{LY`fI#gN6zILD+Mu_ zZDw&5U?pF)Ptd;}BZw3cCiziumd{dh_zRn@0%!&=s#ZCjv6cd7f?a_;yF*sp!I_Kb`HHi}GlAT|`BqhQWt z?lc%En7;0NDfMfVMeXZOWtb4cf!WDMckX<+DU4*^fnsYSSw=N+6EMe?1a@MfAKY+R zoaHIT!spjK7UJ%muZ#Lo(HnnU{0odMxp8vji_8Z8e;?2IziyE^$WuaCZhr=#^q)Ss zP6Z-tyO#BAv7U7#Xf7i=GV3(?ZAuF$;N6h??^v%+U&cY$%PnxPcy-bj?EuqrWjHH; z57uOAby_Zx6Q_(1#WHKE$c<&;X1GU7HZYEc;);es_Ra+M@x*RK*X(t#HV~7;(B#b#U(6}@S~Y)*+SDZQq;0#V zrI~P&?3vybdG2S5vuk29nWG!ohwsvjWng6H7!7N;Ex4NjblA>pkhNhHvv*bq>`o#% zHS)KxJ048ZOI*`>Z=IYnu2THraTG~kIwf)1nP*aj`^ox*}3Eqit}x zR-?qESiN$zAn~-k`L8bmN|(iB+2_v^)fPN!-^P0#{#0ZHC4?-)+xG{3EUxdo1rTxw z+JZWr#hM4a*$>8YoQQHB8-}Lr_J}bZF87ogbAz3lF|xJFTUC<5V6jo3K!;ikr%a9Va zSF?pxhd-=WWz>HMB1^eFsCwG|mUWY`>5V@OC$Oefw30Qb$O!@_L|c|3SK_*B&j15; zSiEmn6%ibfqv@knqNOU`Sk%mv@80ICow$H!XJYFK=To`)Uvv+QJ!=orh1@lK{_7)) zLaL^B7UVz!e6_P({8w#I;mnFMSI!ZtEHA#diQ|<9rr;+RQ16iq{DI68NKeJ3rhxpcL2YyGLp!9uK&1T!~ zKWfKpPBsnurNVk)!{onEtfINPvCc5A?{(;m6njqO^GkJ)nx9@4avi6!lO-059uc+f z{WOk-G?HIyOOC=|&IxO!gs9Ks-SFeF0#zmK4ie*HQ^s#Ew&5BMKiJ!ra;3v?8ly1~ zvReLO2$x#*owhz0qg7EG#X5g0FjKIfb$F`#imyTir-9LHN#0${BTn8<4rh{tAc}1t zjc{l6-lv?f+NCz9Mrv{fmmn=jkjR{r=_i2VtlkXq3jfOz+);*52HUfm1>X!-6tkvl z|8a)qOR0{jQ>A++*MSnU_d+9PJiF6uV)^OxGM$Iqm2jpSv%+i7joTKCQ0|MbwLi$6 zAnNiAm29?Uyw(@jy=nBdj431#{sLXO-bk8833dl~=PKLtn*_*n8pY1OjGoMq6+K-; z;J*Jb2cG(j`AGWi#$W#O?_xxb8#nhb&}7sjBNa5X7_>s)MUU*g?*tt~T>ypCl_=hX zy{1bud}F>c&3eP&k&QClUsB#a9{Dwi;}0+5n*z+7IiHr~bs5d>Yq$EfsCOC_(9oh#u5ZlqH7} zk6j;WIz7Xch`au>Q#fznytC{139cKBEoQF}t;b4(u>ZKu_bSlw#B_j&01v6ln;6?) zrYPg+iB0iuXjiF#UMWD!YcDuP7o;hYl-$!#bj2z}YO=zjS5uQ8uw=02Y2SEi)w$T8 zc;92sYdWlP78|9*p?wxn+nOcyqRr;P+D_QMZ$V)merhVezwbOm)yn=feSrU*aMTui zU2HeJf9)S{-u=lS?5Z7_wa{P2VS%|!%l)nq6yAAF-_M`CKvX368eHK zK!sKJ!PRkyb(dGk3A52&TnSgC3$O5fK2FjAeI5`%9K{jV>j`_x!seYKg5@JV8PjTJ zO<5D)Hiy=I-oCCga75(khjAt`pA^K!gn@1zetYhW)uc4Hz-srZml)xw8XAZ3CPb<4 z7Yam4vLMePvTb4Vy7qMGmmXKP5y=n<4ctwfT8?)gv-A&Bi<6AyxezeG5<$|_>=L*9f&NEqU?>j` zsvY&;eOtQQgVFaG3&dxv`R`o>VEv-~;$8It#+sr6O5;7!LQC?Q?AcOYqfakxzqz%t z`4vf!mf@`G7IO1AdCKBRR(m)Z*Q({+F(XV_egyR`>{g2_>1;_KHauqqrfR_|RnM*o zj%26$i_k2q58t6HLAze7`*9gNCh9)5v&aRqfvm!( zJ#KK%f;XAEld7M~HOoIH@@1C3bv$BQ@FEyP9|FLP=CxQL=f}u`KBuCD;#S-CtJ2HT zSl7NbCffiS6EOzV`$}C2&JsTi;VRJ8zk|xZog^SVK2=}iPn~}Yre@EEg1i)C0>OV6 zB;1Y3`YDq%xzWdzOwv||GjZS-K$kha9`DcRza`=po2C~7YrVeX-y<$!`Qn>Wiwcbm zOUayLV_H$=-Py5u?A+erx0%8kT*yeMw@PBV4p*Hz&vI?^XU5xEki+2t)Yv_*#EfZG z`pzVEoo>UC`(8x1x)jh9wAQgR<<0mEP`)$H=?Fq70t&3|4Up*d;d>d>zz%5OG0)DR1cK8k@9XUGH7i zMuVSWE1ogl_C1FeN4S%s+y|;#Y|EsOuhStU_ClpbDDJ1v#%?}k*QIX=d4#-}Gm8mJ zSHdw9~aM6j51OkltHIX@B24uUjdMXRuj{?!uE`K4*U;5fX33LJJnul zSoy$X5U)GBW^=^-?uI^G>Q#NzTpw&w^o`zw5L}KeO|gEE&apyg9BvUofLqmO6dN>< zHTI`!oj=u7`P|PU66bz>mr;+nea&^)Q?cu^*XVD_e>qeWz22)b3iT3{JPgG;tacF& zuH6M1ZcgH9ia&L#^s>E!Ns?VVfx*fB2gA!s;;JckHMb1>NJRoizs*FwOPcuPi)NuD z&UKBUxllq9$^BjB`R#@76Q`niRt?s1hJ|D-0%N1$*#5hb=O$gmv3BI z0&cv5eseR2hnX)EQ!m@$W=j4rFwAIT8qmt?@dA|RBvs_NQN`ZU=^r*G<1Mt=#8#Wm`2%?a?Ez^w63NnVD#V3U@S*5LR{aNe zOC8wo>s)jm^Lf`EancvzIZ@&zqwe0bCPDwI7MQgFWfVxEvHQK@2M6f(gYG1X(?Z?B zI~7M`*kdrjN~PN1Gm*KI;dz|%n`FY5IR99OH){_KBi|f-`V@h&djF4n@}iBdK2Xc% z0bmUtljKQzNQm)oroYO!e*-2bx&BVt|NgHGW`Fy&zrPGj(t-|uB(9zK`%8a?L=6Au zv;Pl@#&yKPbdM3JLg4syev4yH0aM)kb8MCUv*w=fL1Dv)sN5^^CwS1{YBVu zkLfD*sPG4XO5^}H&=g-?Gxiu)l_}wq*YusHU*z^CenCEasj{=)deCs{vR_#sOQ8Qh z{MG%sg7gh|s|tX1D%MP=Lg}jL-14oT?VumPEJuqVC1`d{2hu=3o+UZx^SsdTgPZo{ zG1zyHA5I2Qwq3V$2MtVe^hVoan6N-;tGfgA9CBKd7qH_WOWG36<>i~z5Q>4O@dv%= zryQ3`VKU&UOVQGQ7#hX}V#=nQUabEk{Mi zd=#vc+E+wD+m85H?b_RGEp&KxL%p&aw;bm@okf*<)Vas#-+-y71&RX6zg?T7p&@aT z-ZrGZz5u&H+*G=B4bzo`*-&KF`V&Vw?!s{63Y5j8x+6B$l<)Ky6441Ked|7b>t_P znJz=%K-bnPi2LF81i?y%t(!NU2F6&nIfQ@Ah?rjh6v`MnFHh44#1oU_1Fx0 zDZDJV9{o4kpFnQcC)Y-_h)gfrA5Ar_9fM26@&Ck*h?kGMf_Lfn2V1Wg8aX{^s;sJ6 z|9B)#^?iipK2Vsi^Wb_}zOI!^nu8&K&iP0m5$EX+mgFan z53|-=UA-he%q}7e5~*$fbCgGGjv+VfZPBa~d+5sstR}1O4 zOaamQDzNJhsxL7OlJ4x=o>Aq0xo-Dd!Q9EshJ&;hy#=C3wGJ0(6wW2WllQT#K za?)pQwc%|bCO$)%YY#k#OU!c4@y%Zu+f}qKbH{fkU%uR-@DB?x1%?%!lxEb7mrZqx8y>FH z%LeGp`Tt>H9@wJO*V9W01Y1 zl$4GQCL6Z~LSjmh=L3ibCx<&zjRf%5v&};l3XXFyT>}Rl<&HmA{u`rlkW!WAv!+0l z=pl9PzAV|+;fJiK`e}sbR0o_-Q9u1w@{yPa()akQ&zBxv*?567M~JR=YBS+zDi}5a z_bdRIQO5CJtE(U0uyD%)8&?LFS2vt~xA}NnthlI_iF4h7hINeAzB;QavP)#NjqoGx=9#1r^7ZyX%KF+J1Ttd6xO7;z7`z5b&@wJ zdDi<*Jh3*!k=%$-)O2{K6T{`yq}`Br>Mlq!t-obMX&s68FVvD*L^Ca`?cz_Ogpe{K zEVwFsgw4tFe7hAjoQ*j7N2@0+KxnriYDG}HUnWIaP9Ztfo_5 zd$0rX2fPX!QIo1eid)v^G*C8%X=`0Oy?qLsIGshbd|BKGo{9;`Q#LA+DvmLK`zUC7 z;$U=cLqQ?nV6yay$!44R+Hl#BAKHJeayVlJ9p0h^Bz|P10jhlm^efn7RUZHa76WDs z%@%_PYp=F!sCS#&)`kFZKdxK*GEQzyQ|#S!f}EI5{ov2h3uGYxqIWpWp8z`%88e^I z`qnckYgFfRv*;fN15?mJOE0l+&0b-o>>>4*2`Gd4GAzMM+euyhQER6+dpiIRaK~nMqgf&dxsjv3!BjKgY%mMTx&43_O?BG|r<0uA69Vn2%NO42`XCDpHr8 zjKT`HG;0nfH4C`6j@&h{ctiv-)472;Y9^EjO@mDNPBZ-oaZB%98l%Y}akXpQ&HYn@ zatp~ZC2cc^o9`E=xtcE9vM4@<4xxl7A5F&>yF0X508v1s<%cI@zQUIJBR*_w_U^CT z{dQCDO-pao#*WS04@kdY{ncT?@_4bMI+WYL42y2uU7v}DP<>xEmeuSj1a9=XmfELo zS=160CbskjhY7y(0^IWxrZK*TRT;}=;_RmH`UNBGJVYNnHL|@FNg`OLO!Wf%-qCpB zQWQ6(5oibr=QV~w%*gho!jTWMdwKj8hE(gHh&eJ!MBd2Esm*@laH>ld?Q&pg6W^wx z6YT?y$R(@bqE+PFXQmjF3^iU|)@Qz*W~!(bdgcssjuYYDHKcbC@!Wn(uzpyG(b53~m-AZ3g?hO~dj= zcHO?2E#;+tY>Y+~7xd(1{T!V+N7jxfTxAa`b05=CNrQy|p2EBV@NuA%3 znl%FV)0>-XW2z=6BZiJicM9q0ZP_wB{n&bE?z1}P0+1zD5aBHJ(TZ_|k(&xDDkwmf zMZjA*{e^!1#B0~5^{-3l9!&F*$Ixe$&3E6{H>gp(P{@IGYMJrEo5pfkJ>BVc?TClwBU%@VME=WpOTIr zyE#acBPiF`;6%^f*zgScU<|(uk24c3F*7{gdtUHX+_HUi83lAVl`i1Cqs6ptd;m3y zn`m>Y@|Gakoxd+G$)Ci*V=IsH&=}*+zxe(2Tyq9Zndbl7y(islA=$MHd=-^S$BGnI zEu-0$1s8_b)_q|b?N$v#sn#0zYs(LEuiDBl*KqK4m9*Y|!<=n2-(*6)i&P-}Y-fdc zlM%QZIHX!$5*JQGwHW$TB)BoO;nM5#Y0vp?e103XmqI0E$5mlpXv>gPbKXN6FfrRw zK5Jfcof`I85=nMLIrVXdvP#OZ6U`lGW!h2_>Fd`+a%kiCeH(DkC%Zr_D5X^!ksPvQ z7fND;Cf#rs!i#X%;$PP+*G#y1{AgSMqVHv$uMfgEjuyL|^Sf_mo}8J8ojwR^n7ofq z7`U^p*aEP5dXB#`pOQ((FsiT^@fTyCMSxA{FDoO#yjy;tp@Qs zSjP56XBM-GUe^qr&!g0~`%KOwt>iDWWg?6T;x`ipUvsuD=^_Vb`vt8jXuvqfyE|G$ zN#AON-y!^4OAGK;<8GYxGfVrgYj&cA+imn>ubdaFfA(fDO>^$m$8WbJHcYg5QuelV z4yqL_?gy`UrrJTuqG=mTRod}DPW67al>j#7{KL>@mxFFoLGVpNbtdi}#6A4@tMkHi z`KR(Pci4{b?WknTZA8_Ga8TT{l!i}WBYuzPmGcbQ*kO`9x%)fJWQ(N3Sn6J@i=BVA z`VHEe^jVF5VXS1kgH%lu5g4OOfe|4%lcwRkFe}*b?ai!p1pm!z;&qXE9Z{uQMGyJE zU~B-n8U=+|ZN^mrahv-MKbjhc6Tzq7De*s|`qsbZVpzCwQf`tVsZ*~^X!2^l$ILG< z_M3^%{SuD3K%?@axj>L0rXERdl)?w>3EkfN(pu5)#()wm&J&|ZNYf`WX4e~!DdIP)= z@sRdzZyQ+2bz(6jrfke!m28nw#>NyKe6v#<6xX(DZiRVk%MNEPt!EZsQA<1fN;(8} zlfSS5hQ6~y%{I*{`+9HJE;|*NSWv*smj#1^$T&hx#yhw4HGGEx)5KDG9_BhuNOm@kVtJwgvdb0jc%&Ct*P&+aoUEOh{@MaE`FomHWd8t zs5!iKZtCv9i4kyX>>m)(wxlsk2_onLsk$saZ`w7G`&-eDp=e#^x?G+yVminA*4QuM zkgi+m&Vnc0>Afw2BTZ^lbyoq+28r&MDyMb#JM-;tu>VrC`}X+8doiYxx~}^%XARB` z!Kb{9sPu-wWpp-F6}zw!Zc1^X*kSI|hBg4AqbPgk(IzKn@`@U0qOk%3QQ`?FYF{=nmr-mWCro5_2UjMJ@7rp?FgwVxt}ZFZ*vHvO z%<0ZXLy`0N#v{z;Rp7DJzOsFc1Y9R2749*)F*H_z@@mP{GIb|ybi$7Vda~*9;Z+KJ zO9Ep6uuSzxE_8oo>I*He(aGRYbe!mrnmWnYZR66GEdm(dHwrqBlJQGt91G_FRqV}p z^d~k1uNA-jHE?~b1x)bwlo|dSJVw-a;#~6(7JRfb2W~Hl zCEy9v#W1TsS7fODz|ipY(nD+1_jD{xtU#l=@k>2dX>aKf|U5_wENrem?Oj^|TV)@`Zc8Q$cb7u8(84xcw{bV$Wb zhG?L5X|+K(X_>C^T@zjeZ4rhp&4@SYF=38~v$T7&C(5;-PY5)Xu_9(hTYo}NzP z-ljrup~pW;dhsrgy~xQ^4EV8zjp!^nvgLD{q<<;JrF}vt!lG>k+W5m4=4_{2V%7!O z)w>Fg{q^XNuKKu4)>BP*+l(7meszPt780Je` zT>?do`3uAY9SwXoHrFLaNA^|b&8{=?1_-}<3a~4RCo&u>g9?;A&9N{hMkhipc1~_Q z8H+8nNDng9#lFqM-U_f!rQZ5a9ifFS2*a>^4^h}vuJ22j<~1e@rLt$hC7jcV`vJ1K zRAo<@F21g$ayRY=R_VWhE&y|@7IGDx8IW+4L&_1oU43QPeofzAQPqh3SdkaY($B)S zcx(On93u7TEoocM+ONt7$80G6Y^`h&O0F6;`9O1=CIV_ZtW1@X#Fm32eofgjJDs#f z*2~?{bS*u{;lbnw6hCf2u>i^-+@v!HmJ_rZY?2|^iSBm^min7yn`D~-z4u;y!qshg zeL{xtuW?cQc}LVvJifZ3CtV!l*MwPsZXWE!HM);cz0H%7FZP0~K^cB6S(eZqd6%5! zELBP}fi29>4~J49TSO9!jng#WNRDmuJsGm>@+&s#<^QDgy#IbUk;4{wU*t?&zrE^r zoQ>UemoE=p6i>Iomb=y_o6BdKB7 ziA2l#5QI(Jrd7vB(j{MAy-fqZ-YyxnyA(B|AcSp2gpVW}xR3yU8Jp|6A6F60Lbg?o zUAQhAQ7q(r;vDR*svl79x)-QJc3bAJrsUy*)?oX#52oPoaESGdKMY2L%Z$ z1me3vV<^EnI)qe-hRhq=oImgySssZ;=tJ?WSiw4PHoJEuKsW9BRxnX2et5HiV*byi@d! zi3>P_xPIG#KATaBy99PaX;<>AO8G||aQE!4ia^B?4aw*4eP!rf<9qT!E+$K?b*G3p zFd<0iG^jE$64o$1F=;aaac?l`e4Wse$@#HrkqVRvbUW8(lF^F_O1&bfRx$x6yyQ@pVM?L@*x1DVEvG@p01<8R%6hX3X$T!SN4&YQdedC%eS|)qm*GcbyKaXM@u|>B3>34Q#{OO zO{LPDzCK$MWV+;mkiOo<>f{RIqVW>uUVxUXg+DU#=#UK?okx;f@ z+j$h1&-=Krj8>r8*ke>dLD{VYCR_R~L!1@H+I}u-X+U{UZC!XIBJD@WN&B~3QT_|H z-)Iy2@{kS_YC;Nbk#^-tv)$TCFejWG{@F4muybBSjWj=&uFO8|?$5q5wk8~NS;}=j z^951a>3G?nq57cwtp)42$J?7pkgekG){zqwL3FNxKho zE2X1cI^tPYL7W*x{TQSQ!Ni)T5)2CGSv44`N-I(D-Ts6Pik_`WWfaP*t`uipl-_G( ziurKQkAIif>fUwJ&|Dir5!AAitT@Q1MW8%LS?!%)W9*g&{VBE<>nE#AdwokLbL( zYnB1$8bZ%cx}67eHMvPf(bZO7wa;Q%5rANj45{00NOaV9T6E6H%J<4dyHkye?!#F< zG~!fBz>kgcorE!F6PV<#M#>+Cp~p2VwJwqMCFQ|j`c<*eUgfwQjYM8QJkuWcffc%u-v|; zJoXg%VT)mT6-=1FhDTa(5g@TGpk3 zPT;S>s}SSKg@EhYlTtdAQ*{V15%;{wv(#NcEsW*@T)DIE`v`}C!sWm`UNxRcutPw? z`;^;iz5YjDbY8tlDW8q!f&y8Fjam#+rx=QM=FJ|j(a7Vp-8;pX+GiTV zv?P1N$jlvo7*3UH%FV_GwYjwVbC`T!1p`7(jv7vd|8ErQlXye%$&h}8|N7?}Q`qNp z5FVihtmD_PxHezI(9gl`#^O>3kE|Yv*WOl;wE66XuaD+kcX_(33Xx_qx+}joD3IZe z$wl_2_d!tTWKHbm{_;Y@Z|w}AiXVj~oFoC``T9E$0B3ht#tDRvHw`HCZ|1s;H=gqF zn^RXGxM>Xx0G1OV5$vWs2}EHLDlqma<*NnI;YJaIuVi{x_?kB;y=q@Pwx0e|(*=_q zg|8IBmMk?mzhjF)N_u-c6b_u)2M6rL40bu(z+ahq?<$!TwHeQiFzv8EHHKshn9V+3 zI=^q(cHnR)s04%?;*OKA4rST!CkU4L9@tK^ZE@FnJv)k1k& zwox2lHSK-EC#kmerx6?dkfMav>@^ zvSdabL^Q~nmj3|-YHR5~U#99t!ubh~<<+%3p9ZR&qGY>l!_JF)Ritlg!3~oL*eGpg zMASyRM)J>>J|MOkH!rYjgH~6LcRt>P8=z(lv@(F>zBP z-IHUSEDTl;T_jadRAOq#Z|wiY-g`wg)%E{_K~z9MiWDh=(xr;hq>4%t5u^$jf+96Q zR9b*Q6r?u+0R^d2g@Dvh6FSnR20{lx=?OJJh;!cm&G`J*dY>t4X05rHi(Cax_St)% zeZJ*WxU5KbcRP+53!?jQ)zcftaOUMrkLvWlH;4cE&ZqUT#-syIJ-( z?-ghVWY<&I5C%kzme~vAa!rEmK!Ch=#lp-sH6XU2=$@O}632zJj~mXv-?F`twR2}1 zA`9iC=MyIwwu6y6qk+QkE(oS#*mISqBZCtx{4M*ftmYc=>0 zk8|n3d~L*)w;r``cY?dm)FIDNM)(;K{>AX(N5f>u7x}KxZLOsFfJYO-@yx9GlXN?M ziISQh8NPGL#t&wa48mHGQ5q~#s%C&!8FYIQic&e@x2pQFC9msVVpkP;wkpHP?!}vq zo71|p$mFoib8WnJyo0f1jlwm&OqUF8|3bR^+PWFh{Zp|u02yMnnw?0@`_ky~_44O9 zaigKo+3d?n7j7BAzSJ+ne!*AQ5`Fdpb9^dRzhd(;FfsVqugAXw=ohdN%8oH!y;_7> zfKSBi#L2-5mZGGgP#tZ`{mY2=IXA1UleKOheSwzF6eQeo$0)&dOoz$$fY)nW6qyIm z=hmWfFIgt?-e_~Xme@=0AqkbxOzzp`QVEq!-OY^2pdwmCsy^KQ=_qa{zyI*QEX-npdm%`}O#pAQBB3(FtNVDmo@*lc9>?nIG&Gp8vwpZGTw z2}6N`@Zc8Cz#BtyoBsFEq5id7`}$5e=@*RLD+W_bJDmhmH&&g~wLNp3-0%I5d!g8B zu$3#c&HM9t3WdWvz6_4k8gCQ?>KpBT z4h4l>_UwlTi^5gSb(UvYI}=Jqq{pY2I~Q{~o;uWJ#?7+AF54hv-d2T;OKy*M>Vx+W33ppPr?YoKelDLu&kC5?exd8oi?7{Cm`*oN)E;E z&KIO!zO^^aBYL|q&sRnNDB;K^;SXpkmtweVrZxr##9fgZNPve88K)ef5OxHnP_mwA zST&Lt4ts!G3fpALvsoJ_%t%`Mz+`WQ(pO#jY)360#^1F~KY_SIqzOUXD|Y6)Tvt9S z8T)7h+67Nhj6!%TJz_pD1Q|E7fxO~@9^3k-V@#HjFO zgIfDD{I0{i$JqNZuMuq(B=;1{f;_}Chvv#XvyCf~1e zkE7r*w)z{V1)a0Id;Syh>gNRkgLZdsHMZCOW{ehHHatH}ECTe~mCZk=s?q_X+hZG# z=`TaPyG5qkdd=|@4$Uf$lr8v){;kTHF%;T4t0!OhFHKpse06SUG3Sn{6mAC{J3L7; zEEi~eU`S6&F(Pb-1d6QNaEwWR8C8V=FBC|)>dhJh`O~IIydKn+v7FDYsb)$)G{m&l z>viNn))gt|2U7UKz|^V~%Jhk)%UEB|QC5N5$Ovi}Mow7wsZ_(ZXY#4aLkVRj#JLc8 zHHj`|vkyZpD^B>&CUNLSWTRCQk_VX-@Rg zpVT_j0=!)1&m8PN4DO9y(GBh9ySdP8Y^a;KjO&G|As0^R^*`+tj6v6M%l>{}CRBlX zSqt(K2eFYKILtesFU;Iy!-`tqSEbUk$@y-%o34(fOnDZBxlRobY0xzBA%J;}RaGoq zlkFSdcFYG58A4`;NeT83xEHE=xuo7Dn&xo8AG!(Jimo?{Eq17c zDxbwW9yn13#HEZLMywRt);amKt5zRj?E8CX;uI4{ehHIy%E)w3v;qR>GP6n20$}Ka ze?U}zFjx+XA_g}!Cu-*{7>EK5iP1(oyGweCVM}Ar6kpC?w2#o&<6cgbG>K^ojyDEu zp>lxw4UmyTwMWV}w}i__<&OCMt!C~o3&(k%U;aK__O^-ZGU&2J&s=3$TU44k$r;Ee zIM(^uG#11OF{5Y`XCDktb(nVO-aS>1Y3V$^j3B4{-4}vxBi||TT`HA>Q!0WEMT8T= zT>V?)Cua#M!?)xKgJgUg4Cb^WQ>pKEo#qR}jqd_Q!ro;8GJyxq&g(w;4Sq>7(g$R| zObIDZE39B95wb~FOfu9YeJSf-(7(QCcz#Z+V5-Uw7|@ZW+hlL2TTN@$(^6Vrdbiv3 z7MmJQQ81DdN`JxTOLl)j&eVOpd!r-Mx&4v5jtQgrKkVY4pV#VE858PMV6kx~O56t+ zs|3e`&=JoxdX&lI%nosZs)~^O)N6l z-$~o?`czG?|GIy`&{OS4XM=9vVaS>f`VP0Vge1!?H!X3Hw|TG~Gg1n=j~fe&RiHwR zD3O%q(qjlJ&XhnChh4Z`PjuCIcEWjZ5v>r@FtDq78m}e#=}>$6sCH^_W_^$g$%~Qb zB?Px<;K!BOFxSVo+*t=7{y?ql*ke8xs|aAmTb_JLeiRn|ecJ|nqW&Ae^LGKVpXv=7 z;TTp@!ePfDrw{++K~28ig{hWHM-W%^Cn2;i%cKxpvY9dMQOHxG<2#H1sbR?{3{`c- zokxO++Nsr~l!R_8^S@YB2tFTPPw!juqnB3}vl6`lsb-&0xh>jzHUp(iX)MAv&GlUn|A4rKM;fW;=U7N8`V~YKc*Q{{?qTeSkw5)wv=~^(*PQOt zoyR}44b_U1Yfp0;4WE4>y|aPy8=!&XeLqS~!)D|UXyK|i+ShS7YQkn79sN~<_`bSeF2bLr*^+82)D8NUb zg`HjyKQcGqY-fdEIGk^T`&femmpYAYq)X(A-{t{bvYJw_OFjolm0dD@B#)J%hPN zI2~T>blP-uxGH9(z>qwk1ys4j;0laQZ%u95VrJ+#ev z<;GEt5y#vsXXSIoV-dtN8fSFo`Oi6LvIbt6g^!Q9nk|M=m?z11Y7)EHT+9|D=~6ju zf1Veo>1qBp&(B>=a12LiEI{5R^J)BOv{j`msHwaW*S78xQ0lbPxOtk$Q27Mb7{l7V z9FjT{&cJb-H@nTtEb<^R+9uM<51L2b%s<>Jja7d1=(D{Pp6rIrS)Zy@+tmUri?;dT zmvqM)ugv!=?3yI6Yz>-J;N(}z(cd!gfa|Gzs)RG%?w9rgZG>5pK5N(ozrheBvlQhz zS$#;=#LZqkH8oloKk)0ku8{e40ahhf!t-p0}%2 zj;fT%ig|10E-VvtAE%^_o%L4)8($|&+>AN#5W5*VSQa>-z$Y*as7AOCwjW63yKoS@ zc{v~sL3mxs_EIUeOtS$?{5GeP zjb!LhlJtuo`(iM24%-J|vbWrXsH68=N;5L1A`d49YO$eRj}!ZM)twS~1RQ@uPO=u` zHrEZ0ff(%#AYnWN6?JhMH*JWS{$Fjr)TSC(gvuavJhmIV3%O zrd_K&{+^c{9Yd*Vox17kN1dH7oxQ#tKi!A?eLXoO%nCVzB%cQ$@)2LFe_3FwINt)^ zy&l=6*@ci9E3j0|@<2+?oXy1KQ&yU!S3V3fXD>Ez(DUtBj>(>zL=t>apE^Z zNdtuJ<*;M}{;BcIy*VJd0FIVL7nD7`pu-9hv?r17Xjy;4m*#Gyub8~!HoX?LQz*3j zNr$=#1l-(Tqxkao#msWvNc}y;|MOqIA|iBtlm<>7Si)ptGD_-&dnTQ6isyN9m^WX> zClsCyw=y}YrV$Qh^c?lX?$7Nwhgl=R*qgY6{en}V6xa;t(xhsjQ5vgQ3N_F}Yag>3 z9QNvUa{=E~ftLwFd6KI?%uMvVapsy=OU2HfZYkrZwS zMdY8$H#h+=)*n;VDt0o**|-6}F;Q>^2zJ6wFO5{{o_!QOop`>F$Kbgt~bu2Lzq zGf-}b!cX%4^%PgypONPNm}{6zBz|UEKs&y(WJg6s{Hs9n^Jb79ic!=g%xZX3p5C)I zmcB6AX?P2-?;P~)EKQ`AMa(x}3!`Sj9XYDve;KcNc4NIhkKWb`OKzpdx%QzVN#Ci3 z#Kk1LeuxR}ok{c_iwj`M{+IvnSyfjZ@q0 zscD@CV&k#@^1Fj;HWI^o=^i=&{JpWs(Bfv4gni6RE=&H=e#hQj(~0b7l1|80qMI!)$l0zB)EIe|k+heHoKpFm)~2zqrm^EJt(f)5M!BzeMwv@85s^+nSevX`a>) z`BGEH<88F4RXRB+ozUB_V~tj>$MZ52wMc|sw$;6w{1hHJ0Cc$lEf&50;#sc#&vW)q z#)8suJgRw!&l}RbZ*(u8eZx5SUw2kXzoK!~xZDZ!guW0~;+|ufRbTU7kFEUMIq+px zF5Rm^N>|5H{hc~6h5xq2%_HyKYP^*Lzt`ecq!`wyVV_l#_Zo-p&v({vP&7f$CWGT+ zw)`t!WLiy8W`XJr(3K)F$^ac^GpKN*SzbZpB4GqG5Jj`azkSSa1Z5x+dkQzVtTX>WZ!rlz?? zSCL9J>*!f{@3x@Obx`~losS&YzmAZhe{9&VV~qY!D+mK&h3nj(|9~7us!n{Rw;U!0 zm)j#Pr(A*_fkZ)<^s3o?z74xquW5<)9RmN*ZKQW#g@uUkyY4By~R5D;DEfnV%$g>P4epUQ}Hhqh-OE60p93X zzx})h^!}+!7*O8C?b~Jj^KwsT>lWhZ;b|S*GU4+20e5$eh9_lCR%re&F_Y z$I6H+==*oD$4Eki=mf=)2_@rM+Qx{q7f#uxFMw&`O-z-LXrlnfg|YmlyqN$%>ZW292Wjx! z*%IvBJ9;y4^EMYvdA9rT92wj52Nd^F9pEfm`~j&1@@WGA&i``0<1{!Ut9s+up2EE) zLQ^cz)%ueQy5Iz{^vG=AGaKg5QSYQ`L!MKCQXy=lVts;5h)2n~BE)XQt%uHGXV#!0 z{c)h#+0T_?^FCkln&}EzrL$NKg6LhL>KMKxGrUA-M-uIU~ecT*bO+u`^A* zQ-jmS`?gU}n;Mi_i(vU;pEoMLwwC|#{z;!(ki@yG(`R>rmqP_*8l&Imp3wx3$oD*u zzxS|?`S;`lJMR$Kc?|uzZAIXKR0H#U&lCBb z8S>=hl~zR7=8>I8;@d78EtQ#d-0riAJ!Pi9AK`z*EhS*+)0!X8YZ6u@y3H3?R7^R{ zZ-JS&CX5$_o`*Bd#(#^v?LeLSBa8kYv++Nk=%PMb9)1;#CctG{0N`Y0$xJ|;0a{Ert=Cs!%S-FXl=XGr zm$$QTfav0{Lt#t`&oUlbesGR5&av_Xa{9KBNN2kubIV~f?TuSqr?yL@a#Kuafk%Os zKTJ9s%KLiIDWND?D4>Pw2XCQUT#$~6^%X|zFMX;}|8{p~E-De@#Q5-b6K^-jB{h(v0r7keSP8EIo+#o zG}(J*5w3*L42l$|BJSJ01>@r48TBM@sRai!+X%&^<-TTR*795I(>KqVqnFntiak8l zF8%j+ZX{0qI?1jrLm=}WX4KM4KS06Y`68{9!)I$N5f4ce_s#Z;;}>{?mM>p@UU1ol z{xrLhWoh%lK-+_cv781>Wx(A|HH)_6(6oc!%-0=j6mMvYHpkPkY$@5k3N}vlM*?)J z#FNZtn_vFpAt`@Y`YRhAb|TS^mzquSl`djL4ZFtg9Z7E#P~Schd6ydwUn^lW{jmoy zpkZzJM_~od*I{|i*VNlwU2|ViJ=}b0sW;cH(mkywl%OY{IS=7?nq#Q~fCRUtXNRPP zs^ejMCsABW$qM-2glX-BYcwZ464?bcn7Dli;T(1eXwXyW{(Jc0f?{J>+{Unz_)Uo} z@F*h)i%UxU3~I-6je7v)Q6RMD!5d{Klt2042E)gwpZ4ZYZ=Cg+`^V#sVsouCZ)s?l z{?{EF`R^M#5|prO`VCW=f!A()vJ&&fA@ug*OWGFkHR|iXLXBzHXiF7aAdCpk`7179 zyGL-YALN?^-Ta1`Cz!jt)v8n9~i*Phtm7|i|Q`M+_?~${qida zMAdxelNQL)*-!MHoCb>v=IRI#W)!UBziAe_B-b0Jc)^*Usjiye7 zN^I43{q?`Z`C@1O@nt(vI|9&jk_sZlwH;U;uO3SHI{?BC2k~yor;~PbToX&83^%m> z#$(w2C6)vh^Z)<$udML@qq6}@fSMPW*C6NIo(R*N@;%%cI+)sUnfv>-)tgAU>kSkc zIt@?hB%*6c>+@+4UeR~UWBFjqGR{Y=NXO~4Yu-agQ7$l5Y-L2xe0X%)z@4dtb64C( z6)gPu;{Fi?{xdu5fgdjNhX!(x9M*w8H_jEImNUgPYvXc>c0@@?;8(Io@Vlp#d?1l5 zqE81C9f_vJu>&JSJQo|XFvQv?vrl(ltR?03*U;tku=}8LRgfXOYPZPL6!PQ7R*FZ( zzl0?1X#fHg8#V_{-BOZ!SFKp)BmJ0_Ij#5e9+mG(9dt0(hI%BBbCRruC2GZ{%{TO# zHr9+)pI7LXbWi&s{+&AI*|1te_dEm9Ovamxw?@i9>P}nZn%u!KbmqRt(BEnFKV#s^ z5?J==27o%r?5nAbv5z2VFWb^8sm3z-Wu!fl?$gUJ{r9(;Q|`Yd?Em9I{(Bf>R{!J6 zWsDG{EntC!D7&>>|8P4Z@vFw+n|>JX{~^hDO`?CZ2N_^JEXrJ%ayuZtRo1Ig_CqnbL-^tTG`5g*d6HYr1MIUoh7WxJz1T* za%6VI$p4d<%E#yNr8d4CNa0-P#;Qox`@S;o#Q%WY=8)fkPSkn=QlXSblO#&mZpDZo zS$<>A5IW-)Ii0UmLmByI=_`gE`$SCd9Jzkp9eCs{B?{b}MqREiaNH?BsFH$e2Khu|_l0Y5trI zb_Dgael$N}(4Z>LB)Ul7!H!PM^!-Li5c$o!Bj_3xA+EW1Q6I}3ca8*y9wz8W4HTay z(ctqvz2K9}lXa05f@ciRvBlm&Ijah1_vg>74(6Wlf3Sxo2JC98qJ(kmU~vrtpyT7q+Lc3+#lN zsD(20ci{#~*T_QE$-U@>+?`RFplD)7ehvZKI(Ux6Z$9c}coBY2^M}0J{X_s?vHITH zz$;l2fzx961CkY85lu|RBva7tv&qh$Lv9t3eq~sq9%M}3Yvt7X=*t4Q6TNV%F;5fe zt_ogsgP9T?vR~!946XS!?&1gC$9+7s&o#(K*eIaG>Dcc_Q^Opl4g9(~y6uTFoiZRe z!0<62tAa4DC3B~f`*U4PoQLOTS`J<@eye*sY(^!VVI02R(8JYJL^FDg;QbZ`%bn`T zUM+9Q)mvE|63%_*nh6#8tu4!i->Q4cd+Xv#4l(R4LXk+X#|@~zs^!5`96WLjy{V><(B{$lO~79MJ*yqD&uH4m|b5qzV=t z_Q-!1{vvsi<&4BV$u}!Td1qu1$+F8)3VMkaV=}5*gv`a1_NmOv@Zbl;U!nV8^VzmX zl{jgE%8|UP^n}j-4VLyxTw}J+MPgcay)Zv_z;fc<<3tGDL*y`W)hQT zYr8v{xa4$ZBBT7RZR)jIu#^Xs)5AxeEADukgWspC4H`1O0aI?U421I{%6)EW3>}1P zx5@*ImGAi$fY;IGYnLMA^j9M9aeQKEUSVsz(fYEa;l{k}jE;#*hV-~RN7S7r;(1iT zZ~R;b&&aZY_L-QRE%4d}5{3cKJnY%t zgTJ@mQZ27Y@)p(*#L78s-9x@aZTCwgncLv4YPBpywSVEhZ_hy#WfnjjH zA>e2M4{K*yJcZ~Fk!97Y<0h-7%6I`!KPUIxq(#=#913l2jEIQRxLRM{(h*2wnV z(5Td)clP0Sk6H-e(H$wh%28=K;Y?^@cpozn6K{GZxDWXu8nL+sJwG?k|HiXPr5Z+4rB!<*0({i}sInl!~LOs>cs-z4s}bFRJ}ce@@L* z?2@;z-J1pdys%B*1*i-gLWOW_V^DOroK&f4Mj060a*beh@gAyq$a?KLdxY`p$KQGZ z{SI$Nk-IbH^QZPsXAc^(OWl3=RJyT~=+c=IRIs_xWhs3pEuWMmZz#y_!+rXp2ioYKxhhd@)LyOU7-K^-ipV@oy@JPTG^M z@$BKiOC&+fQ7_cJhXkLnz4cQ!u$O+Zfi!I%_M+Y^6>(~@=XxrY36Gr3ALTlH$veV4 zo${tsjlN8XPV}4XBAfsno+eMn6!NY5j6a|y&QNK0hlIrySm^j`eYq0LnV4qbj~V~T zg-hcWjhT26#srO^q~OUX-2lMIyo^stcIun8(%=bBvLX&rq4?uitstzbn7-B>Rn@Bk zEp#*Qxt)#H@iB*?}V>?P( z!5Vj>;THNzOMT}Yu<-#tdOz<5Fn;md{RO#PrV3pe#GhGeu7CPCU8Jq;y-Q6EmXJ0_ zdJZkh&2U;ye^G%>)*HVXe_h))(&1cEuk>61fj?Lh=*5_KX_jZKZ1K2l;4DhZ)Y@Y= zg%ZB^e5G$_kiANkta>4;bB=+0jVO)>ju#5skg6HXWkpwDeD61mRYdUId)*SY_)e`C zOYr=H@~>5 z8p6x;pp%f=V~tIAI~Dy9)rSU9mzW|!4hG{bohN`^!O z#r=cjF*lsPu)VN^mz7UN5PBU+8CQJ@8Ay03in*`!3y< z!Dch7;q{N*H=0}YxRN@5v;-CMWoD7D5kx~QEV*TZ5GuKh&J2DZzYS*vZ}#}uJK}@; zX#5G2CtaiCaiL`Tg)$nQvwk#TRj7+fQXRS1^Cz|31>dMBMcfr@rSWn55?!vQSB%AS z3BO45-ZzGXj5W#Cgd)n4IvN@P@k=Fa=K!V2OWmFT3$KRp-y_(ys$V5aS3b*Gui@_B zAKNTNsim(J`?x8Wa0IO>T7IDYnf$!G)`1xmsAcP~W}z2r$m@b(h=Sl`8Wl3AlTws7 zM;^Yg?gFnYykO-5@i3&J-{0u-EwdR9 z2>Fn8Tj ze?U#UjW3c;r7`+-^(m5dDTe@;m~s-ZUA_YgMi|m;{31BcZ^sIbR!7ZUn1kz~U?rv1 z*2TYLmL|HqT!hLd7c5j-`ocwxl#lpO?o~Tt8ChaaG&m|3B z<_x7t(AE*E2zj6WhCk`<+c)Q=(F7iY#u1YV z9>lVk^yyA^eX#7ag;5JM%yItKASA&Jt%g(qvl@WUWNGO5Yh!NtD89mdD*m(kz|C8+ z)%oJas0U@@;}#X04RihIoySwpZxBrj4Aj0rF%;x$h{B;dlpsHv%0Aev2Gu6cHZ7v8 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%0D-rG(t%`4x6{CI7WPnI0!EsEIsUgN668 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 BxnBSP diff --git a/activity_browser/docs/wiki/assets/monte_carlo_results.jpg b/activity_browser/docs/wiki/assets/monte_carlo_results.jpg deleted file mode 100644 index 5e0597d5142946f3b90a95794fae45a1ee32257f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 100234 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≈!d##G$0I0cc>{?YjNBrbV{}1rx&B(AdUS3)xT&i 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 diff --git a/activity_browser/docs/wiki/assets/overview_global_sensitivity_analysis_setup.jpg b/activity_browser/docs/wiki/assets/overview_global_sensitivity_analysis_setup.jpg deleted file mode 100644 index 7880b038c192630f25a430457388164e25464a14..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 52828 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 Td^=z$V)ehr|Gfu1f5!g0Vy#=dZ@KtKfqq(o}y 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 byYdtVS>j{Fw=AHp!GEOh`17*^zeoQc>mnH4 diff --git a/activity_browser/docs/wiki/assets/project_setup_dialog_bio_vsn.png b/activity_browser/docs/wiki/assets/project_setup_dialog_bio_vsn.png deleted file mode 100755 index 1820814fe6f7751885f67512afb23251a85b2950..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10878 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 JYW40s`8Tnnu3rEE diff --git a/activity_browser/docs/wiki/assets/project_setup_dialog_choose_type.png b/activity_browser/docs/wiki/assets/project_setup_dialog_choose_type.png deleted file mode 100755 index 8ba17eca739f5904e4e774312fd9b8818293cb47..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29031 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 YPdqhUf2=VYTm?~8)Kn;vzyIRD0i+6C>i_@% diff --git a/activity_browser/docs/wiki/assets/project_setup_dialog_ei_login.png b/activity_browser/docs/wiki/assets/project_setup_dialog_ei_login.png deleted file mode 100755 index 4294afc33cd621bf43112794f0bbf588c0f7491d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10987 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 bX{Q)*t)Lr{DNpDJQ~~NLT2IR#TZa4}Xg#@u diff --git a/activity_browser/docs/wiki/assets/project_setup_dialog_ei_vsn_and_model.png b/activity_browser/docs/wiki/assets/project_setup_dialog_ei_vsn_and_model.png deleted file mode 100755 index 10e25cf2b6658f489fe46f7dab9210c09f5a5af5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9426 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 y7XC-~P)xQBWA+%-y diff --git a/activity_browser/docs/wiki/assets/project_tab_until_databases.png b/activity_browser/docs/wiki/assets/project_tab_until_databases.png deleted file mode 100755 index fec74c67e6644cde2781f44c59937eac5c3da3ef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11923 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 U;{cAGQ&2qAFi -coal(1.86%)electricity(35.6%)coal(1.86%)steel productionGLO(63%)coal productionGLO(4%)electricity productionGLO(34%) diff --git a/activity_browser/docs/wiki/assets/sdf_addition_combination.png b/activity_browser/docs/wiki/assets/sdf_addition_combination.png deleted file mode 100644 index a46647f668e1203a69b8dfcfa9d387e77b96957c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 337145 zcmeFZg;&*E8#Ri@Iu?on3W7>VgMx~Lpa&2E0i_!S0cq(5UyD+a1}T+Lqy*_yQ97g> zq`SMn`P1`$_Z{QjKjH2%7%IZvYp?aheCC{Md)$&1JFu5>F9`|B0r49mvLqyX@<~W` zN&meI-!Zv)@-qI|Vfl}^{NH#u{e90J|K9W9hKeN#2?rhVwaqR@$Ohj$VI`_;C1<8* zWqa2`m&DfAmczi*$WrI-16>X?3;obZ0ZI~*(pr`n{q~EKSvA(>Hi(OojoiijeH8u5SeHs^MNQkOuNKl@a zomlqc(@eB~yMJn4-RQaN!ma-7#7Wat2SJ)CAw6!#iHnYxxE;IJ@|!M+h@>^eVuAkL zG5+66N~m~wd3{4eS(`N-6B`m##iTpJ~(TlM;4UB093%;I8T?qrT+ra}Gn$=oSH@9}+DoA8r&JR~HyT>NEIZ6;K8 zes~@$R?>1-=$%VYjNh?)cSZhESA2V#0o_DfI_t%i^ZL~%b!sB6Fr2kFZ2ZVzJ=PRJ zWtw$uYh%hai1^E7Rv)QB!v`PJmQOvp$66D$3cHqPN=|XIex0AK;2g^^X?GC#N^fCM zD0clirMbDe?5q3bH8q(V&g;uFAFn;$HIQQPd&fxL)M3k3W(SHLr}FimxVh!#MFa%} z?LWaKvTOhGH=!2{WVNeJJ9Dgy>AVGcZ8Xm@G2P&^9*u6#Fkubnx8;!QX~tgf*V5{) zC>O5|=U3Y5-r9&1v+$K*R{5;=Vy5Vz(D8i~999P5HWO`SS>}CK$(s30F55{+f}Z}~ z4-V{ydEa%N;h%4ddy3pTHM=V+BuP(Rev_A%C*f3`J5^wGg5SnCWOZm_f}OKq`JKz~ zTetqC+(`@muc_KU_V$%AM&>2#sK;wtyOB~0D9&f-l#tS~vE9rt?Nl}NWfm0FylC3d zA{R=e1s*KnvJw%_iz*e5cJ3?hD2{a9jN91QxS~0)SA$pXa$eHdSf8uO4G2mw6g7Li z`(Q?82>1JhvGVcO&&Qsck&wJeFqF61O+xY|K#ZATZm^1b7wJi5Ey1komOad96CO}nqbT)Lh_IBonCK4?(%pfIqi zxmk&bpUkE>h4{esc5{Wk;uixU`Vl^h6YXParrDxI$l;9~rlw}^c3DeI(W|0tw=7)l z-A6{Y@6R4EIp7FsGF>)SX?L2L6|BVKttnCP#J`r6Kt?>@zMHhvmqGG*jAT%tL9S#F zM`LD(g{05GP)%ge__(ph%a?&#t{aU^VfR&2?|HDPq<&wk z2>a_Y5dtcRH(jUMGDcIShyy1t-?AO4i}`NSKR76%t^JPd#0h3E3eHP|HIZg(YF1Wh z0_)R7#U0s}43{srOyu>)Cl`D~V5~@-D&^Py{Q)%32xFODOeymw0Svlp~ zaQ%C4lT)rUKPc)F)p+_m7$ZK+H)DYxPkDBckUSJ)j=9(H;Wla6TRfGD(CTm$!%M|9 zWwyFhUCB;-jo{Y>&3m7uI4l^N+gj1j$-Gb%dQALT8` zb;+#DNhMS8&!&-lq>b@^^5jX2g_dHx0y k=?zr1(QT&)XaAniMZnEAW*3ESx?HK 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�WKJ6;^)VI$Unvy0F8BM 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 z%0AQMixB1rX2mK<7FqA=%5^>L{}s)4kgR6>M3)ouy5r-USc7QIy67d9&awZ&v{iR* zzrSVLss2K|1hT66m6g5&H-AiiI!f=PscFS*+;IZ!T|xZ(5yJzmt%hcDwwVTA)Q19D zGc6pa9PD=g7*(}6gK~XJ%$2(ANtNp}fGv&*mQ#=iMRQ{D!RcWgm84OrhSa^{YO|;j|7!|k+Qk%8xoeUg5 zI%wVPyxkp|*TbQI3rEOzB79Q*EZBdN*C5H+S&QMT6v@Ao93wGcUU%~JX_`4DxO5NF&@kdl zgP2+}Jpy|HxH*-S6i`)DGf_Vx{_53P*8Ad&=qYnXBfXp>03@g^Qsf2fkH#_0;Bpjn z&Tnt6O1V@d8u4b<^X>s=crbDDL)B^K<{f(U&~llN^UI0`o!=F`o6A6dT1v1V+;hU4a`Rx9ZJi~1LPI^+1lTaa=`Y1|&oykAqng9VdYsBQY$FPwLt-?c ze#EwI`y`u$dvJ*RYHBJJO20n(u-Ph@clF`J$}>ME05uv)pJcKd`Bu~8L5xds*35QC z0Ea-hEbm&vgG`@oFXpp*pP9L*Aua^Vp{K4%HUt$K`gs*EziNpMa)1=FthH8ZlSf=~tBZc?FpPw->IOF@5kMMO_ ze&SlVVjQ=YgEdsdQJ$wk`NzPN`onjgUe80eH()|hBUpJk*x7Xv35HL>G4kiOrlyXC zZWD3wq9RwDgW`xZJ?zOPzKJuxtpJthpvA{hO_q}Wv9i9O`9o;oiG07QJ)|>3R#sMO z%pb4Jww4QTolY}w5sZG|u5Wb8qIQDlB78E2f*687iQ)RJSNrN5M%4f%F4ouAx4J2$ z;=(0F-*fBM@mTWAf-xihVq>Gk+~YpJlh9hc&|7q+%XFALIl9E-=dWMGGZn1a+H=-@ zxPvf__U-x%P(e^P<2?q&#T#21+uL`?4oz=v!0f;v7H)DIA4JsSJ-0n9Y~G1aA9HhZ zB2J$!QZ1BGb}M;TcKkFU_epR+6*K;HD3>zAd+2sX#`fGQx3}F=*%J2+@YNkxHNSuh zCzowXduqZTQ0eBeGZ@1+CEj(++p$*u(+0>aIyb#y+3lpqBE53mmpLCfNwZahDZo-v zhm-_A$K|#@%iU*kg{j1rSBR!1{;$qI#-y=0z{+{!?Cm>Bv6?X7p=$78f93FZFO=-` z_p5nL@1Pib>{u=@JZb@{k%tG|N1bBeQ4~uhcPM+Ym32Jz6U%I;=C6}(R}gqpP!yK1zzi&;-lih2#iPL@6s9k*{(bpK@I^WJ+REEC6gRZJtQQ z1h*>kT^U7&5LRsLF1pv*++k)Er;N1Z7d}35kjRVPOSIH4ngWGafw(K*e==UbUs2-7X#s zr}^TkQ);|6X=WAN+0+cpReLdfsOrf!b;mrMQF$3Ubcqr`T!-K*>@jid7~f;|AM?4<$m!N zAx>b2l*`(~v2}oQb_L#jl#1 zHgxIH!{O~OO1z5b@U4!LiyLblFEjYNbS?b`Q)N8W{|{5&0gq+h_J7hsDG7;6gSw1_ zl!mM*D_NHn88;PLR#7r46{2NRA!KDML{vuhNGW7ymn|XuzrXu=pa1*5pHKJadGEV) zUFZ25$MIc9FhdcVx3n|0n^T6TfUK~?ks-*sk&{!2^9`LQC@2pK>vw3DkMPlf2(rTJ*yM_u(Z%Ttz0VV*uhm z&_29F^U6S>Ats7Bgv}IVo^;IRb%QvGa_oD2Bo`sJu`lJux3QgbjpUKC;}IbOS(u5%2H;)*?ZFEmF%OECn;_t4Yu+QaX5& zSF!v%#zxub7#R5uxaJ7Rz_;%l_Z#Q2e^o>r9L4zC!X{->&#smL_YKW@6~e@@qL8lq z4$`5RsW!kOHZR7IQes@_W@2OtKv6I0zz4LK5%7$8TewIh;zlH_Dczs!ZB#m35E>x? zjMPTtX~@l}nLsiw1e+}xpL-udxJ0K_wbj*LplBpJAT=OE$Fp{m!ik7T0rZRQ+gFPB zEe6ZwFrrSZWpM!jqP4&!{1bY{{Xp6&Gtd=e1`M>BDPwMioxttSfZ)Sj^aiyKPC{VM zB=ZpqvIGxtB^3gl^^OxytM0J!mIj{6Pq`A|0Ju`MB5iTv>jVIrdAgVu}$ z@6qRA16x;?8KepoP-JIAzA;APaQF3u-vpkG#wy#Ug`hQee`jQ82c~pFQG*g@?`*~$ ziGSOER?Ve#f-VnvtBlI%(lLw`nd?4UW{j^5WE@P?0qy`Ggy)&E)tNI#>^>wXqkO{r zHrPRvTti}*v~`f+vl16jpc}_sRLp~LLPEpBr@SH)b=bfgF!zt3lGf}gvWD*zG_mrE zaiFT{XkNw1R1u6Y=)Qk|0AlHQ0DCG^)Xv((#NC;ZMrDnP(jknQ)u^!XB31$}2dEc1J`!zw8+ff5vZ|2N(rH8Y z*X2Nlo<_uePki)rw2`Wx0E4{)58a8S4029**(h+_H2AOgP&v+H(UR{`a(;y|BT@Q< z`=8D`#<|+Ud-rlewcWlf)=sI1@T-j+Zg;S3fEJv2nLEq_&#u>OGvIqx_+-}%QRN+=w{XTux+ z&4-Pw94AZTWw0asC^l+B;0Ih1`W2?bx8RGae=f&O2hYtO-G zL*|O=@C%+yY-lXNV`780E@H3>VT0Ob6ZoO@#_)9dL9@ zv{~oipL2!6+*tp0XW~YzqLA^c&^&HgQE^j>h)YQ<&TBHf;5Fo5p64|%!RmA|>L*jXZ7)y4E2M*0l^nKnaMfTPdMn=k6nWC=fL-~!A z;Om9mv5-YTmLTBWCt1(ntR%5?3MJPpDeYWx5EE(*g0U|KafOTXBMtK{_~7Oi z7Vl9H?f8q#wN%NUQAh3&Un=AdXNYc9#{S30tL;*V6(q(x615fhwi3KMq<)tmCRt8l zrIdk3%6(-?!U7+=okV4PoU12x-TL)AUtZ(o4d==HML0)A;j`7Oe)bb_cu_!f61ayV z4I>;5I5lb=+w?#GC`kT}4W<~dVr|n`oY94&`Dl!P3|6VtBd7D3@cj{ZmIIw21~%UD zxSWi}JU;_$Ua@Bk{=Xmi%5+;7hEaSYj7}}tE2@JIqhb=)-5!WF*CI>V#^e=%!30;} zRUFUg%K9y!PeuVMj48R_@%fIU;q$_AY7|H1C{EtSfh+9@Jbls&^mFU3GrGC~&4r=S zs0VZmPr!^M)N%@FW2}7&!cJ!0@R0LGhei#R_O2*`1LUKXgkh}tsq0g8>seUZ^aV}P zjXn9zO8tcezd^DMuE&H$@&XQ?-(E3S3?a=>Rx90!(>)ayD;J2x|A?@qmJUPykR5bb zQZlQiOUkcpw=1o63TMg4hKTj6`m@ zXM4_Q#M;TsPwJfdbP!ja);BklM%#sUw_vxEfeACor;}$3m30V(=}k =p`k&vo`Rn&|S&qsF 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

EE3cHa<{{Nx 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�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 diff --git a/activity_browser/docs/wiki/assets/sdf_product_combination.png b/activity_browser/docs/wiki/assets/sdf_product_combination.png deleted file mode 100644 index dbecb6288ac4ba24c5ebb2781849b98ca99b210d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 345368 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

0Aj7>~d&WugZ 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

sF1$ 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From e2e53479df3186a7c906c27d02d0f69871628b39 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 12:11:32 +0100 Subject: [PATCH 226/267] Updates to top level modules --- activity_browser/README.md | 1 - activity_browser/info.py | 7 ------- 2 files changed, 8 deletions(-) diff --git a/activity_browser/README.md b/activity_browser/README.md index 6850d4586..9eb2b3f5b 100644 --- a/activity_browser/README.md +++ b/activity_browser/README.md @@ -13,7 +13,6 @@ Activity Browser is a Qt-based desktop application that provides a GUI front-end - **`mod/`** - Monkey-patches and modifications to third-party libraries (bw2analyzer, bw2io, etc.) - **`static/`** - Static resources including HTML templates, CSS, icons, fonts, and JavaScript files - **`ui/`** - Core UI components including widgets, dialogs, wizards, and web views -- **`docs/`** - Internal documentation and wiki files ## Key Files diff --git a/activity_browser/info.py b/activity_browser/info.py index 98a8b45bc..fcf3ba875 100644 --- a/activity_browser/info.py +++ b/activity_browser/info.py @@ -1,11 +1,4 @@ -import ast -import os.path from importlib.metadata import PackageNotFoundError, version -from loguru import logger - -from .utils import safe_link_fetch, sort_semantic_versions - - # get AB version try: From f0254989b270e008e15aa1edfc105ebd965c3404 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 13:01:33 +0100 Subject: [PATCH 227/267] UI refactoring --- activity_browser/__init__.py | 21 + activity_browser/__main__.py | 18 - .../actions/activity/activity_new_process.py | 62 +- .../database/database_export_bw2package.py | 6 +- .../actions/database/database_export_excel.py | 7 +- activity_browser/app/dialogs/__init__.py | 1 + .../dialogs/database_select_dialog.py} | 2 +- .../app/dialogs/node_select_dialog.py | 2 +- .../app/pages/lca_results/LCA_results.py | 10 +- .../pages/lca_results}/sankey_navigator.py | 28 +- .../pages/lca_results}/tree_navigator.py | 11 +- activity_browser/ui/README.md | 3 - activity_browser/ui/dialogs/__init__.py | 4 - .../ui/dialogs/new_node_dialog.py | 61 -- activity_browser/ui/dialogs/uncertainty.py | 737 ------------------ activity_browser/ui/web/README.md | 310 -------- activity_browser/ui/web/__init__.py | 4 - activity_browser/ui/web/navigator.py | 503 ------------ activity_browser/ui/web/webutils.py | 15 - activity_browser/ui/widgets/__init__.py | 4 +- .../base.py => widgets/abstract_navigator.py} | 36 +- activity_browser/ui/widgets/cutoff_menu.py | 4 +- .../ui/widgets/database_name_edit.py | 3 +- activity_browser/ui/widgets/formula_edit.py | 2 +- activity_browser/ui/widgets/menu.py | 2 +- activity_browser/ui/widgets/plot.py | 2 +- .../web_engine_page.py} | 7 +- tests/actions/test_activity_actions.py | 2 +- 28 files changed, 132 insertions(+), 1735 deletions(-) rename activity_browser/{ui/dialogs/database_selection_dialog.py => app/dialogs/database_select_dialog.py} (95%) rename activity_browser/{ui/web => app/pages/lca_results}/sankey_navigator.py (96%) rename activity_browser/{ui/web => app/pages/lca_results}/tree_navigator.py (98%) delete mode 100644 activity_browser/ui/dialogs/new_node_dialog.py delete mode 100644 activity_browser/ui/dialogs/uncertainty.py delete mode 100644 activity_browser/ui/web/README.md delete mode 100644 activity_browser/ui/web/__init__.py delete mode 100644 activity_browser/ui/web/navigator.py delete mode 100644 activity_browser/ui/web/webutils.py rename activity_browser/ui/{web/base.py => widgets/abstract_navigator.py} (90%) rename activity_browser/ui/{web/webengine_page.py => widgets/web_engine_page.py} (73%) diff --git a/activity_browser/__init__.py b/activity_browser/__init__.py index 4c44c175e..bc22de453 100644 --- a/activity_browser/__init__.py +++ b/activity_browser/__init__.py @@ -14,5 +14,26 @@ except ImportError: import qtpy +def setup_logging(): + """Configure loguru sinks for console and file logging.""" + from loguru import logger + import os + import platformdirs + + logger.level("SYNC", no=9, color="") + logger.level("TEST", no=19, color="") + + + logger.remove() + logger.add(sys.stderr, level=6, colorize=True, + format="{time:HH:mm:ss} | {level: <8} | {message}") + + log_dir = platformdirs.user_log_dir(appname="ActivityBrowser", appauthor="pylca") + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, "activity_browser.log") + logger.add(log_file, level="DEBUG", rotation="5 MB", retention=5) + def run_activity_browser(): from .__main__ import run_activity_browser + +setup_logging() \ No newline at end of file diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index 077658341..a84595500 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -111,28 +111,11 @@ def run(self): import bw2data, bw2calc, bw2analyzer, bw2io, bw_functional, bw_processing, matrix_utils -def setup_logging(): - """Configure loguru sinks for console and file logging.""" - logger.level("SYNC", no=9, color="") - logger.level("TEST", no=19, color="") - - - logger.remove() - logger.add(sys.stderr, level=6, colorize=True, - format="{time:HH:mm:ss} | {level: <8} | {message}") - - log_dir = platformdirs.user_log_dir(appname="ActivityBrowser", appauthor="pylca") - os.makedirs(log_dir, exist_ok=True) - log_file = os.path.join(log_dir, "activity_browser.log") - logger.add(log_file, level="DEBUG", rotation="5 MB", retention=5) - - def run_activity_browser(): from activity_browser.ui.core.application import ABApplication app = ABApplication() pre_flight_checks() - setup_logging() loader = ABLoader() loader.show() @@ -142,7 +125,6 @@ def run_activity_browser(): def run_activity_browser_no_launcher(): pre_flight_checks() - setup_logging() modules = ModuleThread() modules.run() diff --git a/activity_browser/app/actions/activity/activity_new_process.py b/activity_browser/app/actions/activity/activity_new_process.py index 1ea601192..79725152c 100644 --- a/activity_browser/app/actions/activity/activity_new_process.py +++ b/activity_browser/app/actions/activity/activity_new_process.py @@ -1,13 +1,12 @@ from uuid import uuid4 -from qtpy.QtWidgets import QDialog +from qtpy import QtWidgets import bw2data as bd from activity_browser import app from activity_browser.bwutils.commontasks import database_is_legacy from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -from activity_browser.ui.dialogs.new_node_dialog import NewNodeDialog from .activity_open import ActivityOpen @@ -27,7 +26,7 @@ def run(database_name: str): # ask the user to provide a name for the new activity dialog = NewNodeDialog(app.main_window) # if the user cancels, return - if dialog.exec_() != QDialog.Accepted: + if dialog.exec_() != QtWidgets.QDialog.DialogCode.Accepted: return name, ref_product, unit, location = dialog.get_new_process_data() # if no name is provided, return @@ -72,3 +71,60 @@ def run(database_name: str): prod.save() ActivityOpen.run([new_process.key]) + + +class NewNodeDialog(QtWidgets.QDialog): + """ + Gathers the paremeters for creating a new process. + """ + + def __init__(self, process: bool = True, parent = None): + super().__init__(parent) + layout = QtWidgets.QGridLayout() + row = 0 + if process: + self.setWindowTitle("New process") + layout.addWidget(QtWidgets.QLabel("Process name"), row, 0) + else: + self.setWindowTitle("New product") + layout.addWidget(QtWidgets.QLabel("Product name"), row, 0) + self._process_name_edit = QtWidgets.QLineEdit() + self._process_name_edit.textChanged.connect(self._handle_text_changed) + layout.addWidget(self._process_name_edit, row, 1) + row += 1 + self._ref_product_name_edit = QtWidgets.QLineEdit() + if process: + layout.addWidget(QtWidgets.QLabel("Product name"), row, 0) + layout.addWidget(self._ref_product_name_edit, row, 1) + row += 1 + layout.addWidget(QtWidgets.QLabel("Unit"), row, 0) + self._unit_edit = QtWidgets.QLineEdit("kilogram") + layout.addWidget(self._unit_edit, row, 1) + row += 1 + layout.addWidget(QtWidgets.QLabel("Location"), row, 0) + default_loc = "GLO" if process else "" + self._location_edit = QtWidgets.QLineEdit(default_loc) + layout.addWidget(self._location_edit, row, 1) + row += 1 + self._ok_button = QtWidgets.QPushButton("OK") + self._ok_button.clicked.connect(self.accept) + self._ok_button.setEnabled(False) + layout.addWidget(self._ok_button, row, 0) + cancel_button = QtWidgets.QPushButton("Cancel") + cancel_button.clicked.connect(self.reject) + layout.addWidget(cancel_button, row, 1) + self.setLayout(layout) + + def _handle_text_changed(self, text: str): + self._ok_button.setEnabled(text != "") + self._ref_product_name_edit.setPlaceholderText(text) + + def get_new_process_data(self) -> tuple[str, str, str, str]: + """Return the parameters the user entered.""" + return ( + self._process_name_edit.text(), + self._ref_product_name_edit.text(), + self._unit_edit.text(), + self._location_edit.text() + ) + diff --git a/activity_browser/app/actions/database/database_export_bw2package.py b/activity_browser/app/actions/database/database_export_bw2package.py index 05f7e9485..db5fdd166 100644 --- a/activity_browser/app/actions/database/database_export_bw2package.py +++ b/activity_browser/app/actions/database/database_export_bw2package.py @@ -3,15 +3,13 @@ from qtpy import QtWidgets -from activity_browser.app import application +from activity_browser.app import application, dialogs from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import widgets from activity_browser.bwutils import exporters from activity_browser.ui.core import threading - - class DatabaseExportBW2Package(ABAction): """ ABAction to export database(s) to BW2Package format (.bw2package). @@ -26,7 +24,7 @@ class DatabaseExportBW2Package(ABAction): def run(cls, db_names: List[str] = None): if db_names is None: import bw2data as bd - dialog = widgets.ABDatabaseSelectionDialog( + dialog = dialogs.DatabaseSelectDialog( parent=application.main_window, databases=sorted(bd.databases), title="Select databases to export to BW2Package" diff --git a/activity_browser/app/actions/database/database_export_excel.py b/activity_browser/app/actions/database/database_export_excel.py index c72f0137f..319c83e90 100644 --- a/activity_browser/app/actions/database/database_export_excel.py +++ b/activity_browser/app/actions/database/database_export_excel.py @@ -3,16 +3,13 @@ from qtpy import QtWidgets -from activity_browser.app import application +from activity_browser.app import application, dialogs from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import widgets from activity_browser.bwutils import exporters from activity_browser.ui.core import threading - - - class DatabaseExportExcel(ABAction): """ ABAction to export database(s) to Excel format (.xlsx). @@ -27,7 +24,7 @@ class DatabaseExportExcel(ABAction): def run(cls, db_names: List[str] = None): if db_names is None: import bw2data as bd - dialog = widgets.ABDatabaseSelectionDialog( + dialog = dialogs.DatabaseSelectDialog( parent=application.main_window, databases=sorted(bd.databases), title="Select databases to export to Excel" diff --git a/activity_browser/app/dialogs/__init__.py b/activity_browser/app/dialogs/__init__.py index 65e57911d..5a337f238 100644 --- a/activity_browser/app/dialogs/__init__.py +++ b/activity_browser/app/dialogs/__init__.py @@ -1,2 +1,3 @@ from .import_preview_dialog import ImportPreviewDialog from .node_select_dialog import NodeSelectDialog +from .database_select_dialog import DatabaseSelectDialog diff --git a/activity_browser/ui/dialogs/database_selection_dialog.py b/activity_browser/app/dialogs/database_select_dialog.py similarity index 95% rename from activity_browser/ui/dialogs/database_selection_dialog.py rename to activity_browser/app/dialogs/database_select_dialog.py index ab3c0af26..8639ef2c2 100644 --- a/activity_browser/ui/dialogs/database_selection_dialog.py +++ b/activity_browser/app/dialogs/database_select_dialog.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets -class ABDatabaseSelectionDialog(QtWidgets.QDialog): +class DatabaseSelectDialog(QtWidgets.QDialog): """Dialog to select one or more databases for export.""" def __init__(self, parent=None, databases=None, title="Select databases"): diff --git a/activity_browser/app/dialogs/node_select_dialog.py b/activity_browser/app/dialogs/node_select_dialog.py index 14d935c5d..ffc805af5 100644 --- a/activity_browser/app/dialogs/node_select_dialog.py +++ b/activity_browser/app/dialogs/node_select_dialog.py @@ -3,7 +3,7 @@ import pandas as pd from activity_browser.ui import widgets, core, delegates, icons -from activity_browser.app import metadata, actions +from activity_browser.app import metadata from activity_browser.bwutils.commontasks import refresh_node diff --git a/activity_browser/app/pages/lca_results/LCA_results.py b/activity_browser/app/pages/lca_results/LCA_results.py index b43ec9045..b475f091c 100644 --- a/activity_browser/app/pages/lca_results/LCA_results.py +++ b/activity_browser/app/pages/lca_results/LCA_results.py @@ -15,17 +15,17 @@ from activity_browser.bwutils.commontasks import unit_of_method, get_LCIA_method_name_dict, format_activity_label from activity_browser.bwutils.sensitivity_analysis import GlobalSensitivityAnalysis from activity_browser.mod.bw2analyzer import ABContributionAnalysis -from activity_browser.ui import icons, web, widgets +from activity_browser.ui import icons, widgets from .style import header, horizontal_line, vertical_line from .tables import ContributionTable, InventoryTable, LCAResultsTable from .plots import ContributionPlot, CorrelationPlot, LCAResultsBarChart, LCAResultsPlot, MonteCarloPlot +from .sankey_navigator import SankeyNavigatorWidget +from .tree_navigator import TreeNavigatorWidget ca = ABContributionAnalysis() - - def get_header_layout(header_text: str) -> QtWidgets.QVBoxLayout: vlayout = QtWidgets.QVBoxLayout() vlayout.addWidget(header(header_text)) @@ -118,8 +118,8 @@ def __init__(self, cs_name, mlca, contributions, mc, parent=None): ef=ElementaryFlowContributionTab(self), process=ProcessContributionsTab(self), # ft=FirstTierContributionsTab(self.cs_name, parent=self), - sankey=web.SankeyNavigatorWidget(self.cs_name, parent=self), - tree=web.TreeNavigatorWidget(self.cs_name, parent=self), + sankey=SankeyNavigatorWidget(self.cs_name, parent=self), + tree=TreeNavigatorWidget(self.cs_name, parent=self), mc=MonteCarloTab( self ), # mc=None if self.mc is None else MonteCarloTab(self), diff --git a/activity_browser/ui/web/sankey_navigator.py b/activity_browser/app/pages/lca_results/sankey_navigator.py similarity index 96% rename from activity_browser/ui/web/sankey_navigator.py rename to activity_browser/app/pages/lca_results/sankey_navigator.py index dab378aeb..a71948e4b 100644 --- a/activity_browser/ui/web/sankey_navigator.py +++ b/activity_browser/app/pages/lca_results/sankey_navigator.py @@ -6,7 +6,6 @@ from loguru import logger import bw2calc as bc -import bw2data as bd import numpy from bw_graph_tools.graph_traversal import Edge as GraphEdge from bw_graph_tools.graph_traversal import NewNodeEachVisitGraphTraversal @@ -19,26 +18,13 @@ from activity_browser.mod import bw2data as bd from bw2data.backends import ActivityDataset -from ...bwutils.commontasks import identify_activity_type -from .base import BaseGraph, BaseNavigatorWidget +from activity_browser.bwutils.commontasks import identify_activity_type +from activity_browser.bwutils.filesystem import get_package_path +from activity_browser.ui import widgets - -# TODO: -# switch between percent and absolute values -# when avoided impacts, then the scaling between 0-1 of relative impacts does not work properly -# ability to navigate to activities -# ability to calculate LCA for selected activities -# ability to expand (or reduce) the graph -# save graph as image -# random_graph should not work for biosphere - -# in Javascript: -# - zoom behaviour - - -class SankeyNavigatorWidget(BaseNavigatorWidget): +class SankeyNavigatorWidget(widgets.ABAbstractNavigator): HELP_TEXT = """ LCA Sankey: @@ -46,9 +32,7 @@ class SankeyNavigatorWidget(BaseNavigatorWidget): Green flows: Avoided impacts """ - HTML_FILE = os.path.join( - os.path.abspath(os.path.dirname(__file__)), "../../static/sankey_navigator.html" - ) + HTML_FILE = str(get_package_path() / "static" / "sankey_navigator.html") def __init__(self, cs_name, parent=None): super().__init__(parent, css_file="sankey_navigator.css") @@ -331,7 +315,7 @@ def make_serializable(data: dict) -> dict: return data -class Graph(BaseGraph): +class Graph(widgets.ABAbstractGraph): """ Python side representation of the graph. Functionality for graph navigation (e.g. adding and removing nodes). diff --git a/activity_browser/ui/web/tree_navigator.py b/activity_browser/app/pages/lca_results/tree_navigator.py similarity index 98% rename from activity_browser/ui/web/tree_navigator.py rename to activity_browser/app/pages/lca_results/tree_navigator.py index 50bca12f9..d3d2ba158 100644 --- a/activity_browser/ui/web/tree_navigator.py +++ b/activity_browser/app/pages/lca_results/tree_navigator.py @@ -24,9 +24,7 @@ from activity_browser import app from activity_browser.bwutils.filesystem import get_package_path from activity_browser.bwutils.commontasks import identify_activity_type -from activity_browser.ui.widgets import CheckableComboBox - -from .base import BaseGraph, BaseNavigatorWidget +from activity_browser.ui import widgets class SmallComboBox(QtWidgets.QComboBox): @@ -39,7 +37,8 @@ def __init__(self, parent=None): self.setMaximumWidth(200) self.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContentsOnFirstShow) -class TreeNavigatorWidget(BaseNavigatorWidget): + +class TreeNavigatorWidget(widgets.ABAbstractNavigator): HELP_TEXT = """ LCA Dynamic Tree Navigator: @@ -68,7 +67,7 @@ def __init__(self, cs_name, parent=None): self.func_unit_cb = SmallComboBox() self.method_cb = SmallComboBox() self.scenario_cb = SmallComboBox() - self.tag_cb = CheckableComboBox() + self.tag_cb = widgets.CheckableComboBox() self.cutoff_sb = QtWidgets.QDoubleSpinBox() self.max_calc_sb = QtWidgets.QDoubleSpinBox() self.button_calculate = QtWidgets.QPushButton("Calculate") @@ -326,7 +325,7 @@ def update_graph(self, click_dict: dict) -> None: self.send_json() -class Graph(BaseGraph): +class Graph(widgets.ABAbstractGraph): """ Python side representation of the graph. Functionality for graph navigation (e.g. adding and removing nodes). diff --git a/activity_browser/ui/README.md b/activity_browser/ui/README.md index 33ae27c71..665e545aa 100644 --- a/activity_browser/ui/README.md +++ b/activity_browser/ui/README.md @@ -11,9 +11,7 @@ This module contains reusable UI components, custom widgets, dialog windows, wiz - **`core/`** - Core UI classes including the application class, threading, and tree models - **`delegates/`** - Qt item delegates for custom cell rendering in tables and trees - **`dialogs/`** - Dialog windows for various user interactions -- **`web/`** - Web views for HTML-based visualizations - **`widgets/`** - Reusable custom widget components -- **`wizards/`** - Multi-step wizard dialogs ## Key Files @@ -83,7 +81,6 @@ from activity_browser.ui.dialogs import MyDialog - Inherit from abstract base classes when appropriate - Use qtpy imports for Qt compatibility -- Connect to global signals for cross-component communication - Keep widgets reusable and decoupled from application logic - Follow Qt naming conventions (camelCase for methods) - Emit signals for state changes rather than direct calls diff --git a/activity_browser/ui/dialogs/__init__.py b/activity_browser/ui/dialogs/__init__.py index d50ef14d4..bb65f2768 100644 --- a/activity_browser/ui/dialogs/__init__.py +++ b/activity_browser/ui/dialogs/__init__.py @@ -1,8 +1,4 @@ -from .database_selection_dialog import ABDatabaseSelectionDialog from .list_edit_dialog import ABListEditDialog from .progress_dialog import ABProgressDialog -from .uncertainty import UncertaintyWizard -from .new_node_dialog import NewNodeDialog -from .progress_dialog import ABProgressDialog from .uncertainty_dialog import UncertaintyDialog diff --git a/activity_browser/ui/dialogs/new_node_dialog.py b/activity_browser/ui/dialogs/new_node_dialog.py deleted file mode 100644 index c723d60cb..000000000 --- a/activity_browser/ui/dialogs/new_node_dialog.py +++ /dev/null @@ -1,61 +0,0 @@ - -from typing import Optional, Tuple -from qtpy.QtWidgets import QDialog, QGridLayout, QLabel, QLineEdit, QPushButton, QWidget - - -class NewNodeDialog(QDialog): - """ - Gathers the paremeters for creating a new process. - """ - - def __init__(self, process: bool = True, parent: Optional[QWidget] = None): - super().__init__(parent) - layout = QGridLayout() - row = 0 - if process: - self.setWindowTitle("New process") - layout.addWidget(QLabel("Process name"), row, 0) - else: - self.setWindowTitle("New product") - layout.addWidget(QLabel("Product name"), row, 0) - self._process_name_edit = QLineEdit() - self._process_name_edit.textChanged.connect(self._handle_text_changed) - layout.addWidget(self._process_name_edit, row, 1) - row += 1 - self._ref_product_name_edit = QLineEdit() - if process: - layout.addWidget(QLabel("Product name"), row, 0) - layout.addWidget(self._ref_product_name_edit, row, 1) - row += 1 - layout.addWidget(QLabel("Unit"), row, 0) - self._unit_edit = QLineEdit("kilogram") - layout.addWidget(self._unit_edit, row, 1) - row += 1 - layout.addWidget(QLabel("Location"), row, 0) - default_loc = "GLO" if process else "" - self._location_edit = QLineEdit(default_loc) - layout.addWidget(self._location_edit, row, 1) - row += 1 - self._ok_button = QPushButton("OK") - self._ok_button.clicked.connect(self.accept) - self._ok_button.setEnabled(False) - layout.addWidget(self._ok_button, row, 0) - cancel_button = QPushButton("Cancel") - cancel_button.clicked.connect(self.reject) - layout.addWidget(cancel_button, row, 1) - self.setLayout(layout) - - def _handle_text_changed(self, text: str): - self._ok_button.setEnabled(text != "") - self._ref_product_name_edit.setPlaceholderText(text) - - def get_new_process_data(self) -> Tuple[str, str, str, str]: - """Return the parameters the user entered.""" - return ( - self._process_name_edit.text(), - self._ref_product_name_edit.text(), - self._unit_edit.text(), - self._location_edit.text() - ) - - diff --git a/activity_browser/ui/dialogs/uncertainty.py b/activity_browser/ui/dialogs/uncertainty.py deleted file mode 100644 index 0e86244a5..000000000 --- a/activity_browser/ui/dialogs/uncertainty.py +++ /dev/null @@ -1,737 +0,0 @@ -from loguru import logger - -import numpy as np -import seaborn as sns - -from qtpy import QtCore, QtGui, QtWidgets -from qtpy.QtCore import Signal, Slot -from stats_arrays import uncertainty_choices as uncertainty -from stats_arrays.distributions import * - -from activity_browser.ui.widgets.plot import ABPlot -from activity_browser.bwutils.pedigree import PedigreeMatrix -from activity_browser.bwutils.uncertainty import get_uncertainty_interface, EMPTY_UNCERTAINTY - - - -class UncertaintyWizard(QtWidgets.QWizard): - """Using this wizard, guide the user through selecting an 'uncertainty' - distribution (and related values) for their activity/process exchanges. - - Note that this can also be used for setting uncertainties on parameters - """ - - TYPE = 0 - PEDIGREE = 1 - - complete = Signal(tuple, object) # feed the CF uncertainty back to the origin - - def __init__(self, unc_object: object, parent=None): - super().__init__(parent) - - self.obj = get_uncertainty_interface(unc_object) - self.using_pedigree = False - - self.pedigree = PedigreeMatrixPage(self) - self.type = UncertaintyTypePage(self) - self.pages = (self.type, self.pedigree) - - for i, p in enumerate(self.pages): - self.setPage(i, p) - self.setStartId(self.TYPE) - self.setAttribute(QtCore.Qt.WA_DeleteOnClose) - - self.button(QtWidgets.QWizard.FinishButton).clicked.connect( - self.update_uncertainty - ) - self.pedigree.enable_pedigree.connect(self.used_pedigree) - self.extract_uncertainty() - - @staticmethod - def standard_dist_fields(dist_id: int) -> list: - if dist_id in {2, 3}: - return ["loc", "scale"] - elif dist_id in {4, 7}: - return ["minimum", "maximum"] - elif dist_id in {5, 6}: - return ["loc", "minimum", "maximum"] - elif dist_id in {8, 9, 10, 11, 12}: - return ["loc", "scale", "shape"] - else: - return [] - - @property - def uncertainty_info(self) -> dict: - data = {k: v for k, v in EMPTY_UNCERTAINTY.items()} - data["uncertainty type"] = self.field("uncertainty type") - data["negative"] = bool(self.field("negative")) - for field in self.standard_dist_fields(data["uncertainty type"]): - data[field] = float(self.field(field)) - return data - - @Slot(bool, name="togglePedigree") - def used_pedigree(self, toggle: bool) -> None: - self.using_pedigree = toggle - - @Slot(name="modifyUncertainty") - def update_uncertainty(self): - """Update the uncertainty information of the relevant object, optionally - including a pedigree update. - """ - from activity_browser import app - - self.amount_mean_test() - if self.obj.data_type == "exchange": - app.actions.ExchangeModify.run(self.obj.data, self.uncertainty_info) - if self.using_pedigree: - app.actions.ExchangeModify.run( - self.obj.data, {"pedigree": self.pedigree.matrix.factors} - ) - elif self.obj.data_type == "parameter": - app.actions.ParameterModify.run(self.obj.data, "data", self.uncertainty_info) - if self.using_pedigree: - app.actions.ParameterModify.run( - self.obj.data, "data", self.pedigree.matrix.factors - ) - elif self.obj.data_type == "cf": - self.complete.emit(self.obj.data, self.uncertainty_info) - - def extract_uncertainty(self) -> None: - """Used to extract possibly existing uncertainty information from the - given exchange/parameter - - Exchange objects have uncertainty shortcuts built in, other - objects which sometimes have uncertainty do not. - """ - for k, v in self.obj.uncertainty.items(): - if k in EMPTY_UNCERTAINTY: - self.setField(k, v) - - # If no loc/mean value is set yet, convert the amount. - if not self.field("loc") or self.field("loc") == "nan": - val = getattr(self.obj, "amount", 1.0) - if self.field("uncertainty type") == LognormalUncertainty.id: - val = np.log(val) - self.setField("loc", str(val)) - # Let the other fields default to 'nan' if no values are set. - for f in ("scale", "shape", "maximum", "minimum"): - if not self.field(f): - self.setField(f, "nan") - - def extract_lognormal_loc(self) -> None: - """Special handling for looking at the uncertainty['loc'] field - - This should only be used when the 'original' set uncertainty is - lognormal. - """ - mean = getattr(self.obj, "amount", 1.0) - loc = self.obj.uncertainty.get("loc", np.NaN) - if not np.isnan(loc) and self.obj.uncertainty_type != LognormalUncertainty: - loc = np.log(loc) - if np.isnan(loc): - loc = np.log(mean) - self.setField("loc", str(loc)) - - def amount_mean_test(self) -> None: - """Asks if the 'amount' of the object should be updated to account for - the user altering the loc/mean value. - """ - from activity_browser import app - - uc_type = self.field("uncertainty type") - no_change = {UndefinedUncertainty.id, NoUncertainty.id} - mean = float(self.field("loc")) - if uc_type == LognormalUncertainty.id: - mean = np.exp(mean) - elif uc_type in self.type.mean_is_calculated: - mean = self.type.calculate_mean - if not np.isclose(self.obj.amount, mean) and uc_type not in no_change: - msg = ( - "Do you want to update the 'amount' field to match mean?" - "\nAmount: {}\tMean: {}".format(self.obj.amount, mean) - ) - choice = QtWidgets.QMessageBox.question( - self, - "Amount differs from mean", - msg, - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.Yes, - ) - if choice == QtWidgets.QMessageBox.Yes: - if self.obj.data_type == "exchange": - app.actions.ExchangeModify.run(self.obj.data, {"amount": mean}) - - elif self.obj.data_type == "parameter": - try: - app.actions.ParameterModify.run(self.obj.data, "amount", mean) - except Exception as e: - QtWidgets.QMessageBox.warning( - app.main_window, - "Could not save changes", - str(e), - QtWidgets.QMessageBox.Ok, - QtWidgets.QMessageBox.Ok, - ) - elif self.obj.data_type == "cf": - altered = {k: v for k, v in self.obj.uncertainty.items()} - altered["amount"] = mean - data = [*self.obj.data] - data[1] = altered - self.obj = get_uncertainty_interface(tuple(data)) - - -class UncertaintyTypePage(QtWidgets.QWizardPage): - """Present a list of uncertainty types directly retrieved from the `stats_arrays` package.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setFinalPage(True) - self.dist = None - self.complete = False - self.goto_pedigree = False - self.previous = None - self.mean_is_calculated = { - TriangularUncertainty.id, - UniformUncertainty.id, - DiscreteUniform.id, - BetaUncertainty.id, - } - - # Selection of uncertainty distribution. - box1 = QtWidgets.QGroupBox("Select the uncertainty distribution") - self.distribution = QtWidgets.QComboBox(box1) - self.distribution.addItems([ud.description for ud in uncertainty.choices]) - self.distribution.currentIndexChanged.connect(self.distribution_selection) - self.registerField("uncertainty type", self.distribution, "currentIndex") - self.pedigree = QtWidgets.QPushButton("Use pedigree") - self.pedigree.clicked.connect(self.pedigree_page) - box_layout = QtWidgets.QGridLayout() - box_layout.addWidget(QtWidgets.QLabel("Distribution:"), 0, 0, 2, 1) - box_layout.addWidget(self.distribution, 0, 1, 2, 2) - box_layout.addWidget(self.pedigree, 0, 3, 2, 1) - box1.setLayout(box_layout) - - # Set values for selected uncertainty distribution. - self.field_box = QtWidgets.QGroupBox("Fill out or change required parameters") - self.locale = QtCore.QLocale( - QtCore.QLocale.English, QtCore.QLocale.UnitedStates - ) - self.locale.setNumberOptions(QtCore.QLocale.RejectGroupSeparator) - self.validator = QtGui.QDoubleValidator() - self.validator.setLocale(self.locale) - self.loc = QtWidgets.QLineEdit() - self.loc.setValidator(self.validator) - self.loc.textEdited.connect(self.balance_mean_with_loc) - self.loc.textEdited.connect(self.check_negative) - self.loc.textEdited.connect(self.generate_plot) - self.loc_label = QtWidgets.QLabel("Loc:") - self.mean = QtWidgets.QLineEdit() - self.mean.setValidator(self.validator) - self.mean.textEdited.connect(self.balance_loc_with_mean) - self.mean.textEdited.connect(self.check_negative) - self.mean.textEdited.connect(self.generate_plot) - self.mean_label = QtWidgets.QLabel("Mean:") - self.blocked_label = QtWidgets.QLabel("Mean:") - self.blocked_mean = QtWidgets.QLineEdit("nan") - self.blocked_mean.setDisabled(True) - self.scale = QtWidgets.QLineEdit() - self.scale.setValidator(self.validator) - self.scale.textEdited.connect(self.generate_plot) - self.scale_label = QtWidgets.QLabel("Sigma/scale:") - self.shape = QtWidgets.QLineEdit() - self.shape.setValidator(self.validator) - self.shape.textEdited.connect(self.generate_plot) - self.shape_label = QtWidgets.QLabel("Shape:") - self.minimum = QtWidgets.QLineEdit() - self.minimum.setValidator(self.validator) - self.minimum.textEdited.connect(self.generate_plot) - self.min_label = QtWidgets.QLabel("Minimum:") - self.maximum = QtWidgets.QLineEdit() - self.maximum.setValidator(self.validator) - self.maximum.textEdited.connect(self.generate_plot) - self.max_label = QtWidgets.QLabel("Maximum:") - self.negative = QtWidgets.QRadioButton(self) - self.negative.setChecked(False) - self.negative.setHidden(True) - box_layout = QtWidgets.QGridLayout() - box_layout.addWidget(self.blocked_label, 0, 0) - box_layout.addWidget(self.blocked_mean, 0, 1) - box_layout.addWidget(self.loc_label, 2, 0) - box_layout.addWidget(self.loc, 2, 1) - box_layout.addWidget(self.mean_label, 2, 3) - box_layout.addWidget(self.mean, 2, 4) - box_layout.addWidget(self.scale_label, 4, 0) - box_layout.addWidget(self.scale, 4, 1) - box_layout.addWidget(self.shape_label, 6, 0) - box_layout.addWidget(self.shape, 6, 1) - box_layout.addWidget(self.min_label, 8, 0) - box_layout.addWidget(self.minimum, 8, 1) - box_layout.addWidget(self.max_label, 10, 0) - box_layout.addWidget(self.maximum, 10, 1) - self.field_box.setLayout(box_layout) - - self.registerField("loc", self.loc, "text") - self.registerField("scale", self.scale, "text") - self.registerField("shape", self.shape, "text") - self.registerField("minimum", self.minimum, "text") - self.registerField("maximum", self.maximum, "text") - self.registerField("negative", self.negative, "checked") - - self.plot = SimpleDistributionPlot(self) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(box1) - layout.addWidget(self.field_box) - layout.addWidget(self.plot) - self.setLayout(layout) - - def hide_param(self, *params, hide: bool = True): - if "loc" in params: - self.loc_label.setHidden(hide) - self.loc.setHidden(hide) - if "scale" in params: - self.scale_label.setHidden(hide) - self.scale.setHidden(hide) - if "shape" in params: - self.shape_label.setHidden(hide) - self.shape.setHidden(hide) - if "min" in params: - self.min_label.setHidden(hide) - self.minimum.setHidden(hide) - if "max" in params: - self.max_label.setHidden(hide) - self.maximum.setHidden(hide) - - def special_distribution_handling(self): - """Special kansas city shuffling for this distribution.""" - if self.dist.id == LognormalUncertainty.id: - self.mean.setHidden(False) - self.mean_label.setHidden(False) - # Convert 'mean' to lognormal mean - if self.previous is not None and self.previous != LognormalUncertainty.id: - self.wizard().extract_lognormal_loc() - self.balance_mean_with_loc() - else: - self.mean.setHidden(True) - self.mean_label.setHidden(True) - # Override the lognormal mean and copy the amount in its place - if self.previous and self.previous == LognormalUncertainty.id: - self.loc.setText(str(getattr(self.wizard().obj, "amount", 1))) - # Hide or show additional untouchable 'mean' field. - if self.dist.id in self.mean_is_calculated: - self.blocked_label.setHidden(False) - self.blocked_mean.setHidden(False) - else: - self.blocked_label.setHidden(True) - self.blocked_mean.setHidden(True) - self.loc_label.setText(self.distribution_loc_label) - self.previous = self.dist.id - self.field_box.updateGeometry() - - @property - def distribution_loc_label(self) -> str: - """Many distributions have a special name for the value that is entered - into the 'loc' field. - """ - if self.dist.id == LognormalUncertainty.id: - return "Loc (ln(mean)):" - elif self.dist.id == TriangularUncertainty.id: - return "Mode:" - elif self.dist.id == BetaUncertainty.id: - return "Loc / alpha:" - elif self.dist.id in {GammaUncertainty.id, WeibullUncertainty.id}: - return "Loc / offset:" - else: - return "Mean:" - - @property - def calculate_mean(self) -> float: - """Some distributions do not specifically use a mean to generate - their random values, in those cases present a calculated mean. - - If any of the data is missing or the calculation fails, float('nan') - is returned. - """ - array = self.dist.from_dicts(self.wizard().uncertainty_info) - try: - calc = self.dist.statistics(array).get("mean") - # Catch exception for DiscreteUniform (https://bitbucket.org/cmutel/stats_arrays/pull-requests/5/) - except TypeError: - array = self.dist.fix_nan_minimum(array) - calc = (array["maximum"] + array["minimum"]) / 2 - calc = calc.mean() if isinstance(calc, np.ndarray) else calc - return float(calc) - - @Slot(name="changeDistribution") - def distribution_selection(self): - """Selected distribution and present the correct uncertainty parameters. - - See https://stats-arrays.readthedocs.io/en/latest/index.html for which - fields to show and hide. - """ - self.dist = uncertainty.id_dict[self.distribution.currentIndex()] - - # Huge if/elif tree to ensure the correct fields are shown. - if self.dist.id in {0, 1}: - self.hide_param("loc", "scale", "shape", "min", "max") - elif self.dist.id in {2, 3}: - self.hide_param("shape", "min", "max") - self.hide_param("loc", "scale", hide=False) - elif self.dist.id in {4, 7}: - self.hide_param("loc", "scale", "shape") - self.hide_param("min", "max", hide=False) - elif self.dist.id in {5, 6}: - self.hide_param("scale", "shape") - self.hide_param("loc", "min", "max", hide=False) - elif self.dist.id in {8, 9, 10, 11, 12}: - self.hide_param("min", "max") - self.hide_param("loc", "scale", "shape", hide=False) - self.special_distribution_handling() - self.generate_plot() - - def completed_active_fields(self) -> bool: - """Returns a boolean value based on the distribution id. - If the distribution contains an average, minimum and maximum this forces the - average to exist exclusively within these bounds""" - completed = False - if self.dist.id in {0, 1}: - completed = True - elif self.dist.id in {2, 3}: - completed = all( - [ - field.hasAcceptableInput() and field.text() - for field in (self.loc, self.scale) - ] - ) - elif self.dist.id in {4, 7}: - completed = all( - [ - field.hasAcceptableInput() and field.text() - for field in (self.minimum, self.maximum) - ] - ) - elif self.dist.id in {5, 6}: - completed = all( - [ - field.hasAcceptableInput() and field.text() - for field in (self.minimum, self.maximum, self.loc) - ] - ) and ( - float(self.minimum.text()) - < float(self.loc.text()) - < float(self.maximum.text()) - ) - elif self.dist.id in {8, 9, 10, 11, 12}: - completed = all( - [ - field.hasAcceptableInput() and field.text() - for field in (self.scale, self.shape, self.loc) - ] - ) - return completed - - @Slot(name="locToMean") - def balance_mean_with_loc(self): - if self.loc.text(): - self.mean.setText(str(np.exp(float(self.loc.text())))) - - @Slot(name="meanToLoc") - def balance_loc_with_mean(self): - if not self.mean.hasAcceptableInput(): - self.loc.setText("nan") - return - val = float(self.mean.text() if self.mean.text() else "nan") - val = -1 * val if val < 0 else val - self.loc.setText(str(np.log(val) if val != 0 else float("nan"))) - - @Slot(name="testValueNegative") - def check_negative(self) -> None: - """Determine which QLineEdit to use to set the negative value. - - Another special edge-case for the lognormal distribution. - """ - if not self.mean.hasAcceptableInput(): - return - val = float(self.mean.text() if self.mean.text() else "nan") - if self.dist.id == LognormalUncertainty.id and val < 0: - self.setField("negative", True) - else: - self.setField("negative", False) - - def initializePage(self) -> None: - self.distribution_selection() - self.balance_mean_with_loc() - - def nextId(self) -> int: - if self.goto_pedigree: - return UncertaintyWizard.PEDIGREE - return -1 - - def isComplete(self) -> bool: - return self.complete - - @Slot(name="gotoPedigreePage") - def pedigree_page(self) -> None: - self.goto_pedigree = True - self.wizard().next() - - @Slot(name="regenPlot") - def generate_plot(self) -> None: - """Called whenever a value changes, (re)generate the plot. - - Also tests if all of the visible QLineEdit fields have valid values. - """ - self.complete = self.completed_active_fields() - no_dist = self.dist.id in {UndefinedUncertainty.id, NoUncertainty.id} - if self.complete or no_dist: - array = self.dist.from_dicts(self.wizard().uncertainty_info) - if self.dist.id in self.mean_is_calculated: - mean = self.calculate_mean - self.blocked_mean.setText(str(mean)) - if self.dist.id == LognormalUncertainty.id: - mean = self.dist.statistics(array).get("median") - elif no_dist: - mean = self.wizard().obj.amount - else: - mean = self.dist.statistics(array).get("mean") - data = self.dist.random_variables(array, 1000) - if not np.any(np.isnan(data)): - self.plot.plot(data, mean) - self.completeChanged.emit() - - -class PedigreeMatrixPage(QtWidgets.QWizardPage): - """Guide the user through filling out a pedigree matrix. - - There are 5 indicators used, each carrying a score from 1 to 5 - with 1 indicating 'less uncertain' and 5 'more uncertain'. - - NOTE: Currently, the pedigree matrix will always default to a lognormal distribution. - - NOTE: using terms and quoting from the paper: - 'Empirically based uncertainty factors for the pedigree matrix in ecoinvent' (2016) - doi: 10.1007/s11367-013-0670-5 - """ - - enable_pedigree = Signal(bool) - - def __init__(self, parent=None): - super().__init__(parent) - self.setFinalPage(True) - self.matrix = None - - self.field_box = QtWidgets.QGroupBox("Fill out or change required parameters") - self.locale = QtCore.QLocale( - QtCore.QLocale.English, QtCore.QLocale.UnitedStates - ) - self.locale.setNumberOptions(QtCore.QLocale.RejectGroupSeparator) - self.validator = QtGui.QDoubleValidator() - self.validator.setLocale(self.locale) - self.loc = QtWidgets.QLineEdit() - self.loc.setValidator(self.validator) - self.loc.textEdited.connect(self.balance_mean_with_loc) - self.loc.textEdited.connect(self.check_negative) - self.loc.textEdited.connect(self.check_complete) - self.mean = QtWidgets.QLineEdit() - self.mean.setValidator(self.validator) - self.mean.textEdited.connect(self.balance_loc_with_mean) - self.mean.textEdited.connect(self.check_negative) - self.mean.textEdited.connect(self.check_complete) - box_layout = QtWidgets.QGridLayout() - box_layout.addWidget(QtWidgets.QLabel("Loc (ln(mean)):"), 0, 0) - box_layout.addWidget(self.loc, 0, 1) - box_layout.addWidget(QtWidgets.QLabel("Mean:"), 0, 3) - box_layout.addWidget(self.mean, 0, 4) - self.field_box.setLayout(box_layout) - - box = QtWidgets.QGroupBox("Select pedigree values") - - self.reliable = QtWidgets.QComboBox(box) - self.reliable.addItems( - [ - "1) Verified data based on measurements", - "2) Verified data partly based on assumptions", - "3) Non-verified data partly based on qualified measurements", - "4) Qualified estimate", - "5) Non-qualified estimate", - ] - ) - self.complete = QtWidgets.QComboBox(box) - self.complete.addItems( - [ - "1) Representative relevant data from all sites, over an adequate period", - "2) Representative relevant data from >50% sites, over an adequate period", - "3) Representative relevant data from <50% sites OR >50%, but over shorter period", - "4) Representative relevant data from one site OR some sites but over shorter period", - "5) Representativeness unknown", - ] - ) - self.temporal = QtWidgets.QComboBox(box) - self.temporal.addItems( - [ - "1) Data less than 3 years old", - "2) Data less than 6 years old", - "3) Data less than 10 years old", - "4) Data less than 15 years old", - "5) Data age unknown or more than 15 years old", - ] - ) - self.geographical = QtWidgets.QComboBox(box) - self.geographical.addItems( - [ - "1) Data from area under study", - "2) Average data from larger area in which area under study is included", - "3) Data from area with similar production conditions", - "4) Data from area with slightly similar production conditions", - "5) Data from unknown OR distinctly different area", - ] - ) - self.technological = QtWidgets.QComboBox(box) - self.technological.addItems( - [ - "1) Data from enterprises, processes and materials under study", - "2) Data from processes and materials under study, different enterprise", - "3) Data from processes and materials under study from different technology", - "4) Data on related processes and materials", - "5) Data on related processes on lab scale OR from different technology", - ] - ) - self.reliable.currentIndexChanged.connect(self.check_complete) - self.complete.currentIndexChanged.connect(self.check_complete) - self.temporal.currentIndexChanged.connect(self.check_complete) - self.geographical.currentIndexChanged.connect(self.check_complete) - self.technological.currentIndexChanged.connect(self.check_complete) - - box_layout = QtWidgets.QGridLayout() - box_layout.addWidget(QtWidgets.QLabel("Reliability"), 0, 0, 2, 2) - box_layout.addWidget(self.reliable, 0, 2, 2, 3) - box_layout.addWidget(QtWidgets.QLabel("Completeness"), 2, 0, 2, 2) - box_layout.addWidget(self.complete, 2, 2, 2, 3) - box_layout.addWidget(QtWidgets.QLabel("Temporal correlation"), 4, 0, 2, 2) - box_layout.addWidget(self.temporal, 4, 2, 2, 3) - box_layout.addWidget(QtWidgets.QLabel("Geographical correlation"), 6, 0, 2, 2) - box_layout.addWidget(self.geographical, 6, 2, 2, 3) - box_layout.addWidget( - QtWidgets.QLabel("Further technological correlation"), 8, 0, 2, 2 - ) - box_layout.addWidget(self.technological, 8, 2, 2, 3) - box.setLayout(box_layout) - - self.plot = SimpleDistributionPlot(self) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.field_box) - layout.addWidget(box) - layout.addWidget(self.plot) - self.setLayout(layout) - - def cleanupPage(self): - self.enable_pedigree.emit(False) - - def initializePage(self): - # if the parent contains an 'obj' with uncertainty, extract data - self.setField("uncertainty type", 2) - self.loc.setText(self.field("loc")) - self.balance_mean_with_loc() - obj = getattr(self.wizard(), "obj") - try: - matrix = PedigreeMatrix.from_dict(obj.uncertainty.get("pedigree", {})) - self.pedigree = matrix.factors - except AssertionError as e: - logger.info("Could not extract pedigree data: {}".format(str(e))) - self.pedigree = {} - self.check_complete() - - def nextId(self): - """Ensures that 'Next' button does not show.""" - return -1 - - @property - def pedigree(self) -> tuple: - return ( - self.reliable.currentIndex() + 1, - self.complete.currentIndex() + 1, - self.temporal.currentIndex() + 1, - self.geographical.currentIndex() + 1, - self.technological.currentIndex() + 1, - ) - - @pedigree.setter - def pedigree(self, data: dict) -> None: - self.reliable.setCurrentIndex(data.get("reliability", 1) - 1) - self.complete.setCurrentIndex(data.get("completeness", 1) - 1) - self.temporal.setCurrentIndex(data.get("temporal correlation", 1) - 1) - self.geographical.setCurrentIndex(data.get("geographical correlation", 1) - 1) - self.technological.setCurrentIndex( - data.get("further technological correlation", 1) - 1 - ) - - @Slot(name="locToMean") - def balance_mean_with_loc(self): - self.setField("loc", self.loc.text()) - if self.loc.text(): - self.mean.setText(str(np.exp(float(self.loc.text())))) - - @Slot(name="meanToLoc") - def balance_loc_with_mean(self): - if not self.mean.hasAcceptableInput(): - self.loc.setText("nan") - return - val = float(self.mean.text() if self.mean.text() else "nan") - val = -1 * val if val < 0 else val - loc_val = str(np.log(val)) if val != 0 else "nan" - self.loc.setText(loc_val) - self.setField("loc", loc_val) - - @Slot(name="testValueNegative") - def check_negative(self) -> None: - """Determine which QLineEdit to use to set the negative value. - - Another special edge-case for the lognormal distribution. - """ - if not self.mean.hasAcceptableInput(): - return - val = float(self.mean.text() if self.mean.text() else "nan") - if val < 0: - self.setField("negative", True) - else: - self.setField("negative", False) - - @Slot(name="constructPedigreeMatrix") - def check_complete(self) -> None: - self.matrix = PedigreeMatrix.from_numbers(self.pedigree) - self.setField("scale", self.matrix.calculate()) - self.generate_plot() - - @Slot(name="regenPlot") - def generate_plot(self) -> None: - """Called whenever a value changes, (re)generate the plot. - - Also tests if all of the visible QLineEdit fields have valid values. - """ - array = LognormalUncertainty.from_dicts(self.wizard().uncertainty_info) - median = LognormalUncertainty.statistics(array).get("median") - data = LognormalUncertainty.random_variables(array, 1000) - if not np.any(np.isnan(data)): - self.plot.plot(data, median) - self.enable_pedigree.emit(True) - - -class SimpleDistributionPlot(ABPlot): - def plot(self, data: np.ndarray, mean: float, label: str = "Value"): - self.reset_plot() - try: - sns.histplot(data.T, kde=True, stat="density", ax=self.ax, edgecolor="none") - except RuntimeError as e: - logger.error("{}: Plotting without KDE.".format(e)) - sns.histplot( - data.T, kde=False, stat="density", ax=self.ax, edgecolor="none" - ) - self.ax.set_xlabel(label) - self.ax.set_ylabel("Probability density") - # Add vertical line at given mean of x-axis - self.ax.axvline(mean, label="Mean / amount", c="r", ymax=0.98) - self.ax.legend(loc="upper right") - _, height = self.canvas.get_width_height() - self.setMinimumHeight(height / 2) - self.canvas.draw() diff --git a/activity_browser/ui/web/README.md b/activity_browser/ui/web/README.md deleted file mode 100644 index d4c7ce935..000000000 --- a/activity_browser/ui/web/README.md +++ /dev/null @@ -1,310 +0,0 @@ -# web - -Web views for HTML-based visualizations and interactive content. - -## Overview - -This directory contains components for embedding web content within Activity Browser using Qt's WebEngine. These web views enable rich, interactive visualizations using HTML, CSS, and JavaScript. - -## Purpose - -Web views provide: -- **Interactive visualizations** - Sankey diagrams, force-directed graphs, trees -- **Rich content** - HTML-formatted reports and documentation -- **JavaScript libraries** - D3.js, Plotly, Cytoscape.js, etc. -- **External content** - Embedded web pages and resources - -## Qt WebEngine - -Activity Browser uses `QWebEngineView` (via qtpy): -- Chromium-based rendering engine -- Full HTML5, CSS3, JavaScript support -- Communication between Python and JavaScript -- Secure isolated context - -## Common Use Cases - -### Graph Visualizations -Force-directed graphs showing activity relationships: -- Node positioning algorithms -- Interactive exploration -- Zoom and pan -- Node/edge highlighting - -### Sankey Diagrams -Flow visualizations for LCA results: -- Material/energy flows -- Contribution analysis -- Interactive filtering -- Export to image - -### Tree Navigators -Hierarchical data exploration: -- Collapsible tree structures -- Search and filter -- Click to expand/collapse -- Path highlighting - -### Charts and Plots -Interactive data visualization: -- Line charts -- Bar charts -- Scatter plots -- Heatmaps -- Custom visualizations - -## Architecture - -### Python Side -```python -from qtpy.QtWebEngineWidgets import QWebEngineView -from qtpy.QtCore import QUrl - -class MyWebView(QWebEngineView): - def __init__(self, parent=None): - super().__init__(parent) - self.load_content() - - def load_content(self): - # Load from file - html_path = Path(__file__).parent / "template.html" - self.setUrl(QUrl.fromLocalFile(str(html_path))) - - def send_data_to_js(self, data): - # Execute JavaScript - js_code = f"updateData({json.dumps(data)});" - self.page().runJavaScript(js_code) -``` - -### JavaScript Side -```javascript -// In HTML template -function updateData(data) { - // Process data from Python - renderVisualization(data); -} - -// Send data to Python (via callback) -function notifyPython(message) { - // Setup bridge or use callback mechanism -} -``` - -## Python-JavaScript Communication - -### Python → JavaScript -Execute JavaScript from Python: -```python -self.page().runJavaScript("updateChart(data);") -``` - -With callback: -```python -def handle_result(result): - print(f"JavaScript returned: {result}") - -self.page().runJavaScript("getData();", handle_result) -``` - -### JavaScript → Python -Via `QWebChannel` (recommended): - -Python: -```python -from qtpy.QtWebChannel import QWebChannel -from qtpy.QtCore import QObject, pyqtSlot - -class Bridge(QObject): - @pyqtSlot(str) - def receive_message(self, message): - print(f"Received: {message}") - -channel = QWebChannel() -bridge = Bridge() -channel.registerObject("bridge", bridge) -self.page().setWebChannel(channel) -``` - -JavaScript: -```javascript -new QWebChannel(qt.webChannelTransport, function(channel) { - var bridge = channel.objects.bridge; - bridge.receive_message("Hello from JavaScript"); -}); -``` - -## HTML Templates - -Templates are stored in `activity_browser/static/`: -- `activity_graph.html` - Activity relationship graphs -- `sankey_navigator.html` - Sankey diagrams -- `tree_navigator.html` - Tree structures -- `navigator.html` - Base navigator template - -### Template Structure -```html - - - - - Visualization - - - - -

- - - -``` - -## JavaScript Libraries - -Common libraries used: -- **D3.js** - Data-driven visualizations -- **Cytoscape.js** - Graph visualization and analysis -- **Plotly** - Interactive charts -- **vis.js** - Network and timeline visualizations -- **Sigma.js** - Graph rendering - -## Loading Content - -### From File -```python -from pathlib import Path -from qtpy.QtCore import QUrl - -html_file = Path(__file__).parent / "static" / "graph.html" -self.setUrl(QUrl.fromLocalFile(str(html_file))) -``` - -### From String -```python -html_content = """ - - - -

Hello World

- - -""" -self.setHtml(html_content) -``` - -### From URL -```python -self.setUrl(QUrl("https://example.com")) -``` - -## Development Guidelines - -When creating web views: - -1. **Use templates** - Store HTML in static/ directory -2. **Isolate code** - Separate HTML, CSS, JavaScript files -3. **Handle loading** - Show spinner while content loads -4. **Error handling** - Handle JavaScript errors gracefully -5. **Responsive design** - Handle window resizing -6. **Secure content** - Validate external resources -7. **Performance** - Optimize for large datasets -8. **Testing** - Test in actual web view (not just browser) -9. **Communication** - Use QWebChannel for Python↔JS -10. **Documentation** - Document expected data format - -## Data Transfer - -### Sending Large Data -For large datasets, consider: -- JSON serialization -- Chunked transfer -- Data compression -- Lazy loading - -```python -import json - -def send_data(self, data): - json_data = json.dumps(data) - js_code = f"loadData({json_data});" - self.page().runJavaScript(js_code) -``` - -### Receiving Data -```python -def request_data(self): - def callback(result): - data = json.loads(result) - self.process_data(data) - - self.page().runJavaScript("getData();", callback) -``` - -## Performance Optimization - -### Large Datasets -- Render subsets (pagination, windowing) -- Use canvas instead of SVG for many elements -- Implement level-of-detail -- Cache rendered content - -### Interactions -- Debounce frequent events -- Throttle animations -- Use requestAnimationFrame -- Optimize DOM manipulation - -## Debugging - -### JavaScript Console -Access console from Python: -```python -def on_console_message(self, level, message, line, source): - print(f"JS: {message} (line {line})") - -self.page().javaScriptConsoleMessage = on_console_message -``` - -### Developer Tools -Enable debugging (development only): -```python -from qtpy.QtWebEngineWidgets import QWebEngineSettings - -settings = self.page().settings() -settings.setAttribute( - QWebEngineSettings.DeveloperExtrasEnabled, - True -) -``` - -## Example: Simple Graph View - -```python -from qtpy.QtWebEngineWidgets import QWebEngineView -from pathlib import Path -import json - -class GraphView(QWebEngineView): - def __init__(self, parent=None): - super().__init__(parent) - - # Load template - template = Path(__file__).parent / "graph.html" - self.setUrl(QUrl.fromLocalFile(str(template))) - - def display_graph(self, nodes, edges): - """Send graph data to JavaScript.""" - data = { - "nodes": nodes, - "edges": edges - } - js = f"renderGraph({json.dumps(data)});" - self.page().runJavaScript(js) -``` - -## Security Considerations - -- **Validate external content** - Don't load untrusted URLs -- **Sanitize data** - Escape user input before sending to JS -- **Content Security Policy** - Restrict resource loading -- **HTTPS for external** - Use secure connections -- **Isolate sensitive data** - Don't expose secrets to JS diff --git a/activity_browser/ui/web/__init__.py b/activity_browser/ui/web/__init__.py deleted file mode 100644 index b8839b90a..000000000 --- a/activity_browser/ui/web/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- -from .navigator import GraphNavigatorWidget -from .sankey_navigator import SankeyNavigatorWidget -from .tree_navigator import TreeNavigatorWidget diff --git a/activity_browser/ui/web/navigator.py b/activity_browser/ui/web/navigator.py deleted file mode 100644 index 78308196c..000000000 --- a/activity_browser/ui/web/navigator.py +++ /dev/null @@ -1,503 +0,0 @@ -import itertools -import json -import os -from copy import deepcopy -from typing import Optional -from loguru import logger - -import networkx as nx -from qtpy import QtWidgets -from qtpy.QtCore import Slot - -from activity_browser import app -from bw2data import Database, get_activity, databases, Edge -from bw2data.backends import ExchangeDataset, ActivityDataset - -from ...bwutils.commontasks import identify_activity_type, get_activity_name -from .base import BaseGraph, BaseNavigatorWidget - - - - -# TODO: -# save graph as image -# zoom reverse direction between canvas and minimap -# break long geographies into max length -# enable other layouts (e.g. force) -# random_graph should not work for biosphere -# a selection possibility method would be nice if many nodes are to be added up/downstream (now the only way is to open all and close those that one is not interested in) - -# ISSUES: -# - tooltips show values, but these are not scaled to a product system, i.e. the do not make sense as a system - - -class GraphNavigatorWidget(BaseNavigatorWidget): - HELP_TEXT = """ - How to use the Graph Navigator: - - EXPANSION MODE (DEFAULT): - Click on activities to expand graph. - - click: expand upwards - - click + shift: expand downstream - - click + alt: delete activity - - Checkbox "Add only direct up-/downstream exchanges" - there are two ways to expand the graph: - 1) adding direct up-/downstream nodes and connections (DEFAULT). - 2) adding direct up-/downstream nodes and connections AS WELL as ALL OTHER connections between the activities in the graph. - The first option results in cleaner (but not complete) graphs. - - Checkbox "Remove orphaned nodes": by default nodes that do not link to the central activity (see title) are removed (this may happen after deleting nodes). Uncheck to disable. - - Checkbox "Flip negative flows" (experimental): Arrows of negative product flows (e.g. from ecoinvent treatment activities or from substitution) can be flipped. - The resulting representation can be more intuitive for understanding the physical product flows (e.g. that wastes are outputs of activities and not negative inputs). - - - NAVIGATION MODE: - Click on activities to jump to specific activities (instead of expanding the graph). - """ - HTML_FILE = os.path.join( - os.path.abspath(os.path.dirname(__file__)), "../../static/navigator.html" - ) - - def __init__(self, parent=None, key=None): - super().__init__(parent, css_file="navigator.css") - self.setObjectName(get_activity_name(get_activity(key), str_length=30)) - self.key = key - self.tab = parent - - self.graph = Graph() - - # default settings - self.navigation_label = itertools.cycle( - ["Current mode: Expansion", "Current mode: Navigation"] - ) - self.selected_db = None - - self.button_navigation_mode = QtWidgets.QPushButton(next(self.navigation_label)) - self.checkbox_direct_only = QtWidgets.QCheckBox( - "Add only direct up-/downstream exchanges" - ) - self.checkbox_remove_orphaned_nodes = QtWidgets.QCheckBox( - "Remove orphaned nodes" - ) - self.checkbox_flip_negative_edges = QtWidgets.QCheckBox("Flip negative flows") - self.layout = QtWidgets.QVBoxLayout() - - # Prepare graph - self.draw_graph() - - # Construct layout and set signals. - self.construct_layout() - self.update_graph_settings() - self.connect_signals() - - if key: - self.selected_db = key[0] - self.new_graph(key) - - @Slot(name="loadFinishedHandler") - def load_finished_handler(self) -> None: - """Executed when webpage has been loaded for the first time or refreshed. - This is needed to resend the json data the first time after the page has completely loaded. - """ - # print(time.time(), ": load finished") - self.send_json() - - def connect_signals(self): - super().connect_signals() - self.button_navigation_mode.clicked.connect(self.toggle_navigation_mode) - # signals.database_selected.connect(self.set_database) - self.bridge.update_graph.connect(self.update_graph) - # checkboxes - self.checkbox_direct_only.stateChanged.connect(self.update_graph_settings) - self.checkbox_remove_orphaned_nodes.stateChanged.connect( - self.update_graph_settings - ) - self.checkbox_flip_negative_edges.stateChanged.connect( - self.update_graph_settings - ) - self.checkbox_flip_negative_edges.stateChanged.connect(self.reload_graph) - databases.metadata_changed.connect(self.sync_graph) - - def sync_graph(self): - """Sync the graph with the current project.""" - self.graph.update(delete_unstacked=False) - self.send_json() - try: - self.setObjectName(get_activity_name(get_activity(self.key), str_length=30)) - except ActivityDataset.DoesNotExist: - logger.debug("Graph activity no longer exists. Closing tab.") - self.tab.close_tab_by_tab_name(self.tab.get_tab_name(self)) - - def construct_layout(self) -> None: - """Layout of Graph Navigator""" - self.label_help.setVisible(False) - - # checkbox all_exchanges_in_graph - self.checkbox_direct_only.setChecked(True) - self.checkbox_direct_only.setToolTip( - "When adding activities, show product flows between ALL activities or just selected up-/downstream flows" - ) - - # checkbox remove orphaned nodes - self.checkbox_remove_orphaned_nodes.setChecked(True) - self.checkbox_remove_orphaned_nodes.setToolTip( - "When removing activities, automatically remove those that have no further connection to the original product" - ) - - # checkbox flip negative edges - self.checkbox_flip_negative_edges.setChecked(False) - self.checkbox_flip_negative_edges.setToolTip( - "Flip negative product flows (e.g. from ecoinvent treatment activities or from substitution)" - ) - # Controls Layout - hl_controls = QtWidgets.QHBoxLayout() - hl_controls.addWidget(self.button_back) - hl_controls.addWidget(self.button_forward) - hl_controls.addWidget(self.button_navigation_mode) - hl_controls.addWidget(self.button_refresh) - hl_controls.addWidget(self.button_random_activity) - hl_controls.addWidget(self.button_toggle_help) - hl_controls.addStretch(1) - - # Checkboxes Layout - hl_checkboxes = QtWidgets.QHBoxLayout() - hl_checkboxes.addWidget(self.checkbox_direct_only) - hl_checkboxes.addWidget(self.checkbox_remove_orphaned_nodes) - hl_checkboxes.addWidget(self.checkbox_flip_negative_edges) - hl_checkboxes.addStretch(1) - - # Layout - self.layout.addLayout(hl_controls) - self.layout.addLayout(hl_checkboxes) - self.layout.addWidget(self.label_help) - self.layout.addWidget(self.view) - self.setLayout(self.layout) - - def update_graph_settings(self): - self.graph.direct_only = self.checkbox_direct_only.isChecked() - self.graph.remove_orphaned = self.checkbox_remove_orphaned_nodes.isChecked() - self.graph.flip_negative_edges = self.checkbox_flip_negative_edges.isChecked() - - @property - def is_expansion_mode(self) -> bool: - return "Expansion" in self.button_navigation_mode.text() - - @Slot(name="toggleNavigationMode") - def toggle_navigation_mode(self): - mode = next(self.navigation_label) - self.button_navigation_mode.setText(mode) - logger.info(f"Switched to: {mode}") - self.checkbox_remove_orphaned_nodes.setVisible(self.is_expansion_mode) - self.checkbox_direct_only.setVisible(self.is_expansion_mode) - - def new_graph(self, key: tuple) -> None: - logger.info(f"New Graph for key: {key}") - self.graph.new_graph(key) - self.send_json() - - @Slot(name="reload_graph") - def reload_graph(self) -> None: - app.signals.new_statusbar_message.emit("Reloading graph") - self.graph.update(delete_unstacked=False) - - @Slot(object, name="update_graph") - def update_graph(self, click_dict: dict) -> None: - """ - Update graph based on user command (click+keyboard) and settings. - Settings: - - navigation or expansion mode - - add all or only direct up/downstream nodes - User commands: - - mouse (left or right button) - - additional keyboard keys (shift, alt) - Behaviour: see HELP text - """ - key = click_dict["key"] - keyboard = click_dict["keyboard"] - - # interpret user command: - if not self.is_expansion_mode: # do not expand - self.new_graph(key) - else: - if keyboard["alt"]: # delete node - logger.info(f"Deleting node: {key}") - self.graph.reduce_graph(key) - else: # expansion mode - logger.info(f"Expanding graph: {key}") - if keyboard["shift"]: # downstream expansion - logger.info("Adding downstream nodes.") - self.graph.expand_graph(key, down=True) - else: # upstream expansion - logger.info("Adding upstream nodes.") - self.graph.expand_graph(key, up=True) - self.send_json() - - def set_database(self, name): - """Saves the currently selected database for graphing a random activity""" - self.selected_db = name - - @Slot(name="random_graph") - def random_graph(self) -> None: - """Show graph for a random activity in the currently loaded database.""" - if self.selected_db: - self.new_graph(Database(self.selected_db).random().key) - else: - QtWidgets.QMessageBox.information( - None, "Not possible.", "Please load a database first." - ) - - -class Graph(BaseGraph): - """Python side representation of the graph. - Functionality for graph navigation (e.g. adding and removing nodes). - A JSON representation of the graph (edges and nodes) enables its use in javascript/html/css. - """ - - def __init__(self): - super().__init__() - self.central_activity = None - self.nodes = None - self.edges = None - - # some settings - self.direct_only = True # for a graph expansion: add only direct up-/downstream nodes instead of all connections between the activities in the graph - self.remove_orphaned = True # remove nodes that are isolated from the central_activity after a deletion - self.flip_negative_edges = False # show true flow direction of edges (e.g. for ecoinvent treatment activities, or substitutions) - - def update(self, delete_unstacked: bool = True) -> None: - self.update_datasets() - super().update(delete_unstacked) - self.json_data = self.get_json_data() - - def update_datasets(self): - """Update the activities in the graph.""" - try: - self.nodes = [get_activity(act.key) for act in self.nodes] - self.edges = [Edge(document=ExchangeDataset.get_by_id(exc._document.id)) for exc in self.edges] - except (ActivityDataset.DoesNotExist, ExchangeDataset.DoesNotExist): - try: - get_activity(self.central_activity.key) # test whether the activity still exists - self.new_graph(self.central_activity.key) # if so, create a new graph - except ActivityDataset.DoesNotExist: - logger.warning("Graph activity no longer exists.") - self.nodes = [] - self.edges = [] - - def store_previous(self) -> None: - self.stack.append((deepcopy(self.nodes), deepcopy(self.edges))) - - def store_future(self) -> None: - self.forward_stack.append(self.stack.pop()) - self.nodes, self.edges = self.stack.pop() - - def retrieve_future(self) -> None: - self.nodes, self.edges = self.forward_stack.pop() - - @staticmethod - def upstream_and_downstream_nodes(key: tuple) -> (list, list): - """Returns the upstream and downstream activity objects for a key.""" - activity = get_activity(key) - upstream_nodes = [ex.input for ex in activity.technosphere()] - downstream_nodes = [ex.output for ex in activity.upstream()] - return upstream_nodes, downstream_nodes - - @staticmethod - def upstream_and_downstream_exchanges(key: tuple) -> (list, list): - """Returns the upstream and downstream Exchange objects for a key. - - act.upstream refers to downstream exchanges; brightway is confused here) - """ - activity = get_activity(key) - return [ex for ex in activity.technosphere()], [ - ex for ex in activity.upstream() - ] - - @staticmethod - def inner_exchanges(nodes: list) -> list: - """Returns all exchanges (Exchange objects) between a list of nodes.""" - node_keys = set(node.key for node in nodes) - exchanges = itertools.chain(node.technosphere() for node in nodes) - return [ - ex - for ex in exchanges - if all(k in node_keys for k in (ex["input"], ex["output"])) - ] - - def remove_outside_exchanges(self) -> None: - """ - Ensures that all exchanges are exclusively between nodes of the graph - (i.e. removes exchanges to previously existing nodes). - """ - self.edges = [ - e for e in self.edges if all(k in self.nodes for k in (e.input, e.output)) - ] - - def new_graph(self, key: tuple) -> None: - """Creates a new JSON graph showing the up- and downstream activities for the activity key passed. - Args: - key (tuple): activity key - Returns: - JSON data as a string - """ - self.central_activity = get_activity(key) - - # add nodes - up_nodes, down_nodes = Graph.upstream_and_downstream_nodes(key) - self.nodes = [self.central_activity] + up_nodes + down_nodes - - # add edges - # self.edges = self.inner_exchanges(self.nodes) - up_exs, down_exs = Graph.upstream_and_downstream_exchanges(key) - self.edges = up_exs + down_exs - self.update() - - def expand_graph(self, key: tuple, up=False, down=False) -> None: - """ - Adds up-, downstream, or both nodes to graph. - Different behaviour for "direct nodes only" or "all nodes (inner exchanges)" modes. - """ - up_nodes, down_nodes = Graph.upstream_and_downstream_nodes(key) - - # Add Nodes - if up and not down: - self.nodes = list(set(self.nodes + up_nodes)) - elif down and not up: - self.nodes = list(set(self.nodes + down_nodes)) - elif up and down: - self.nodes = list(set(self.nodes + up_nodes + down_nodes)) - - # Add Edges / Exchanges - if self.direct_only: - up_exs, down_exs = Graph.upstream_and_downstream_exchanges(key) - if up and not down: - self.edges += up_exs - elif down and not up: - self.edges += down_exs - elif up and down: - self.edges += up_exs + down_exs - else: # all - self.edges = Graph.inner_exchanges(self.nodes) - self.update() - - def reduce_graph(self, key: tuple) -> None: - """ - Deletes nodes from graph. - Different behaviour for "direct nodes only" or "all nodes (inner exchanges)" modes. - Can lead to orphaned nodes, which can be removed or kept. - """ - if key == self.central_activity.key: - logger.warning("Cannot remove central activity.") - return - act = get_activity(key) - self.nodes.remove(act) - if self.direct_only: - self.remove_outside_exchanges() - else: - self.edges = Graph.inner_exchanges(self.nodes) - - if self.remove_orphaned: # remove orphaned nodes - self.remove_orphaned_nodes() - - self.update() - - def remove_orphaned_nodes(self) -> None: - """ - Remove orphaned nodes from graph using the networkx. - Orphaned nodes are defined as having no path to the central_activity. - """ - - def format_as_weighted_edges(exchanges, activity_objects=False): - """Returns the exchanges as a list of weighted edges (from, to, weight) for networkx.""" - if activity_objects: - return ((ex.input, ex.output, ex.amount) for ex in exchanges) - else: # keys - return ((ex["input"], ex["output"], ex["amount"]) for ex in exchanges) - - # construct networkx graph - G = nx.MultiGraph() - for node in self.nodes: - G.add_node(node.key) - G.add_weighted_edges_from(format_as_weighted_edges(self.edges)) - - # identify orphaned nodes - # checks each node in current dataset whether it is connected to central node - # adds node_id of orphaned nodes to list - orphaned_node_ids = ( - node - for node in G.nodes - if not nx.has_path(G, node, self.central_activity.key) - ) - - count = 1 - for count, key in enumerate(orphaned_node_ids, 1): - act = get_activity(key) - self.nodes.remove(act) - logger.info(f"Removed ORPHANED nodes: {count}") - - # update edges again to remove those that link to nodes that have been deleted - self.remove_outside_exchanges() - - def get_json_data(self) -> Optional[str]: - """ - Make the JSON graph data from a list of nodes and edges. - - Args: - nodes: a list of nodes (Activity objects) - edges: a list of edges (Exchange objects) - Returns: - A JSON representation of this. - """ - if not self.nodes: - logger.info("Graph has no nodes (activities).") - return - - data = { - "nodes": [Graph.build_json_node(act) for act in self.nodes], - "edges": [ - Graph.build_json_edge(exc, self.flip_negative_edges) - for exc in self.edges - ], - "title": self.central_activity.get("reference product"), - } - # print("JSON DATA (Nodes/Edges):", len(nodes), len(edges)) - # print(data) - return json.dumps(data) - - @staticmethod - def build_json_node(act) -> dict: - """Take an activity and return a valid JSON document.""" - return { - "database": act.key[0], - "id": act.key[1], - "product": act.get("reference product") or act.get("name"), - "name": act.get("name"), - "location": act.get("location"), - "class": identify_activity_type(act), - } - - @staticmethod - def build_json_edge(exc, flip_negative: bool) -> dict: - """Take an exchange object and return a valid JSON document. - - ``flip_negative`` will change the direction of the edge to represent - the correct physical flow direction. However, this is experimental, - and may not be reflected in the actual display of the product/flow. - """ - product = exc.input - reference = product.get("reference product") or product.get("name") - amount = exc.get("amount") - from_act, to_act = exc.input, exc.output - if flip_negative and amount < 0: - from_act, to_act = to_act, from_act - amount = abs(amount) - return { - "source_id": from_act.key[1], - "target_id": to_act.key[1], - "amount": amount, - "unit": exc.get("unit"), - "product": reference, - "tooltip": "{:.3g} {} of {}".format( - amount, exc.get("unit", ""), reference - ), - } diff --git a/activity_browser/ui/web/webutils.py b/activity_browser/ui/web/webutils.py deleted file mode 100644 index f300ffabd..000000000 --- a/activity_browser/ui/web/webutils.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -import os - -# type "localhost:3999" in Chrome for DevTools of AB web content -from activity_browser.bwutils.filesystem import get_package_path - -os.environ["QTWEBENGINE_REMOTE_DEBUGGING"] = "3999" - - -def get_static_js_path(file_name: str = "") -> str: - return str(get_package_path() / "static" / "javascript" / file_name) - - -def get_static_css_path(file_name: str = "") -> str: - return str(get_package_path() / "static" / "css" / file_name) \ No newline at end of file diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index 441e38813..8f0434434 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -21,4 +21,6 @@ from .drop_overlay import ABDropOverlay from .tree_view import ABTreeView from .buttons import ABCloseButton, ABMinimizeButton -from .tab_widget import ABTabWidget \ No newline at end of file +from .tab_widget import ABTabWidget +from .web_engine_page import ABWebEnginePage +from .abstract_navigator import ABAbstractNavigator, ABAbstractGraph diff --git a/activity_browser/ui/web/base.py b/activity_browser/ui/widgets/abstract_navigator.py similarity index 90% rename from activity_browser/ui/web/base.py rename to activity_browser/ui/widgets/abstract_navigator.py index f729e77d8..065e8b563 100644 --- a/activity_browser/ui/web/base.py +++ b/activity_browser/ui/widgets/abstract_navigator.py @@ -5,22 +5,16 @@ from typing import Type from loguru import logger -import bw2data as bd - from qtpy import QtWebChannel, QtWebEngineWidgets, QtWidgets from qtpy.QtCore import QObject, Qt, QUrl, Signal, Slot -from activity_browser import app +from activity_browser.ui.icons import qicons from activity_browser.bwutils import filesystem -from ...ui.icons import qicons -from . import webutils -from .webengine_page import Page - +from .web_engine_page import ABWebEnginePage - -class BaseNavigatorWidget(QtWidgets.QWidget): +class ABAbstractNavigator(QtWidgets.QWidget): HELP_TEXT = """ This is the text shown when the user presses 'help'. """ @@ -30,14 +24,14 @@ def __init__(self, parent=None, css_file: str = "", *args, **kwargs): super().__init__(parent) # Graph object subclassed from BaseGraph. - self.graph: Type[BaseGraph] + self.graph: Type[ABAbstractGraph] # Setup JS / Qt interactions self.bridge = Bridge(self) self.channel = QtWebChannel.QWebChannel(self) self.channel.registerObject("bridge", self.bridge) self.view = QtWebEngineWidgets.QWebEngineView(self) - self.page = Page(self.view) + self.page = ABWebEnginePage(self.view) self.view.setPage(self.page) self.view.loadFinished.connect(self.load_finished_handler) self.view.setContextMenuPolicy(Qt.PreventContextMenu) @@ -78,23 +72,17 @@ def toggle_help(self) -> None: def go_forward(self) -> None: if self.graph.forward(): - app.signals.new_statusbar_message.emit("Going forward.") self.send_json() - else: - app.signals.new_statusbar_message.emit("No data to go forward to.") def go_back(self) -> None: if self.graph.back(): - app.signals.new_statusbar_message.emit("Going back.") self.send_json() - else: - app.signals.new_statusbar_message.emit("No data to go back to.") def send_json(self) -> None: if self.graph.json_data is None: return self.bridge.graph_ready.emit(self.graph.json_data) - css_path = webutils.get_static_css_path(self.css_file) + css_path = get_static_css_path(self.css_file) with open(css_path, "r") as css_file: css_code = css_file.read() @@ -114,6 +102,9 @@ def random_graph(self) -> None: def savefilepath(default_file_name: str, file_filter: str = ALL_FILTER): + from activity_browser.bwutils import filesystem + import bw2data as bd + default = default_file_name or "Graph SVG Export" safe_name = bd.utils.safe_filename(default, add_hash=False) filepath, _ = QtWidgets.QFileDialog.getSaveFileName( @@ -166,7 +157,7 @@ def download_triggered(self, svg: str): to_svg(svg) -class BaseGraph(object): +class ABAbstractGraph(object): def __init__(self): self.json_data = None # stores previous graphs, if any, and enables back/forward buttons @@ -218,3 +209,10 @@ def save_json_to_file(self, filename: str = "graph_data.json") -> None: filepath = os.path.join(os.path.dirname(__file__), filename) with open(filepath, "w") as outfile: json.dump(self.json_data, outfile) + +def get_static_js_path(file_name: str = "") -> str: + return str(filesystem.get_package_path() / "static" / "javascript" / file_name) + + +def get_static_css_path(file_name: str = "") -> str: + return str(filesystem.get_package_path() / "static" / "css" / file_name) \ No newline at end of file diff --git a/activity_browser/ui/widgets/cutoff_menu.py b/activity_browser/ui/widgets/cutoff_menu.py index 397d5ae2f..e0f1f5016 100644 --- a/activity_browser/ui/widgets/cutoff_menu.py +++ b/activity_browser/ui/widgets/cutoff_menu.py @@ -9,7 +9,6 @@ from collections import namedtuple from typing import Union -import numpy as np from qtpy import QtCore from qtpy.QtCore import QLocale, Qt, Signal, Slot from qtpy.QtGui import QDoubleValidator, QIntValidator @@ -413,6 +412,7 @@ def log_value(self) -> Union[int, float]: This function converts the 1-100 values and modifies these to 0.001-100 on a logarithmic scale. Rounding is done based on magnitude. """ + import numpy as np # Logarithmic math refresher: # BOP = Base, Outcome Power; @@ -437,6 +437,8 @@ def log_value(self) -> Union[int, float]: @log_value.setter def log_value(self, value: float) -> None: """Modify value from 0.001-100 to 1-100 logarithmically and set slider to value.""" + import numpy as np + value = int(float(value) * np.power(10, 3)) log_val = np.log10(value).round(3) set_val = log_val * 20 diff --git a/activity_browser/ui/widgets/database_name_edit.py b/activity_browser/ui/widgets/database_name_edit.py index 5cc210a4b..0d6aa05c2 100644 --- a/activity_browser/ui/widgets/database_name_edit.py +++ b/activity_browser/ui/widgets/database_name_edit.py @@ -1,7 +1,5 @@ from qtpy import QtWidgets, QtCore -import bw2data as bd - class DatabaseNameEdit(QtWidgets.QWidget): """ @@ -73,5 +71,6 @@ def setText(self, text: str): self.database_name.setText(text) def willOverwrite(self) -> bool: + import bw2data as bd return self.database_name.text() in bd.databases diff --git a/activity_browser/ui/widgets/formula_edit.py b/activity_browser/ui/widgets/formula_edit.py index 3adfea37d..47920f135 100644 --- a/activity_browser/ui/widgets/formula_edit.py +++ b/activity_browser/ui/widgets/formula_edit.py @@ -5,7 +5,7 @@ from asteval import make_symbol_table, Interpreter -from qtpy.QtWidgets import QApplication, QWidget, QCompleter, QTableView, QSizePolicy +from qtpy.QtWidgets import QApplication, QWidget, QCompleter, QTableView from qtpy.QtGui import QPainter, QColor, QFontMetrics, QFontDatabase, QPainterPath, QPen, QFont from qtpy.QtCore import QTimer, Qt, QAbstractTableModel, QModelIndex diff --git a/activity_browser/ui/widgets/menu.py b/activity_browser/ui/widgets/menu.py index 367745b8b..c0399b076 100644 --- a/activity_browser/ui/widgets/menu.py +++ b/activity_browser/ui/widgets/menu.py @@ -1,5 +1,5 @@ from qtpy import QtWidgets -from typing import Callable, Optional +from typing import Callable from inspect import signature diff --git a/activity_browser/ui/widgets/plot.py b/activity_browser/ui/widgets/plot.py index b8a1b84cf..b9bdf1f27 100644 --- a/activity_browser/ui/widgets/plot.py +++ b/activity_browser/ui/widgets/plot.py @@ -1,9 +1,9 @@ - from qtpy import QtWidgets from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg from matplotlib.figure import Figure + class ABPlot(QtWidgets.QWidget): ALL_FILTER = "All Files (*.*)" PNG_FILTER = "PNG (*.png)" diff --git a/activity_browser/ui/web/webengine_page.py b/activity_browser/ui/widgets/web_engine_page.py similarity index 73% rename from activity_browser/ui/web/webengine_page.py rename to activity_browser/ui/widgets/web_engine_page.py index 7da8506a2..07f0a67ea 100644 --- a/activity_browser/ui/web/webengine_page.py +++ b/activity_browser/ui/widgets/web_engine_page.py @@ -1,14 +1,9 @@ -"""Custom page for debugging javascript code. Without this code, - only console.error messages are printed to python output. - This code will not tell you the javascript file that the error is in.""" from loguru import logger from qtpy.QtWebEngineWidgets import QWebEnginePage - - -class Page(QWebEnginePage): +class ABWebEnginePage(QWebEnginePage): def javaScriptConsoleMessage(self, level: QWebEnginePage.JavaScriptConsoleMessageLevel, message: str, line: str, _: str): if level == QWebEnginePage.InfoMessageLevel: logger.info(f"JS Info (Line {line}): {message}") diff --git a/tests/actions/test_activity_actions.py b/tests/actions/test_activity_actions.py index fb9df0f17..33c57dcf6 100644 --- a/tests/actions/test_activity_actions.py +++ b/tests/actions/test_activity_actions.py @@ -48,7 +48,7 @@ def test_activity_duplicate(basic_database): # # def test_activity_new(monkeypatch, basic_database): - from activity_browser.ui.dialogs.new_node_dialog import NewNodeDialog + from activity_browser.app.actions.activity.activity_new_process import NewNodeDialog monkeypatch.setattr( NewNodeDialog, "exec_", staticmethod(lambda *args, **kwargs: True) From 7c43c0715a2efa9f34620494198016d20a491a8c Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 13:33:25 +0100 Subject: [PATCH 228/267] Clean up workflows --- .github/workflows/install-canary.yaml | 101 +----------- .github/workflows/just-release.yaml | 28 ---- .github/workflows/main.yaml | 181 ---------------------- .github/workflows/manual_update_wiki.yml | 12 -- activity_browser/ui/delegates/README.md | 108 ++----------- activity_browser/ui/delegates/__init__.py | 2 - activity_browser/ui/delegates/database.py | 32 ---- activity_browser_beta/README.md | 154 ------------------ activity_browser_beta/__init__.py | 40 ----- 9 files changed, 14 insertions(+), 644 deletions(-) delete mode 100644 .github/workflows/just-release.yaml delete mode 100644 .github/workflows/main.yaml delete mode 100644 .github/workflows/manual_update_wiki.yml delete mode 100644 activity_browser/ui/delegates/database.py delete mode 100644 activity_browser_beta/README.md delete mode 100644 activity_browser_beta/__init__.py diff --git a/.github/workflows/install-canary.yaml b/.github/workflows/install-canary.yaml index 066bb8958..fccc5d456 100644 --- a/.github/workflows/install-canary.yaml +++ b/.github/workflows/install-canary.yaml @@ -3,113 +3,16 @@ on: schedule: # Run the tests once every 24 hours to catch dependency problems early - cron: '0 7 * * *' - push: - branches: - - install-canary jobs: - canary-installs: - timeout-minutes: 12 - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-13] - python-version: ["3.10", "3.11"] - defaults: - run: - shell: bash -l {0} - steps: - - name: Setup python ${{ matrix.python-version }} conda environment - uses: conda-incubator/setup-miniconda@v3 - with: - python-version: ${{ matrix.python-version }} - miniconda-version: "latest" - - name: Install activity-browser - run: | - conda create -y -n ab -c conda-forge --solver libmamba activity-browser python=${{ matrix.python-version }} - - name: Environment info - run: | - conda activate ab - conda list - conda env export - conda env export -f env.yaml - - name: Upload final environment as artifact - uses: actions/upload-artifact@v4 - with: - name: env-${{ matrix.os }}-${{ matrix.python-version }} - path: env.yaml - - # also run install with micromamba instead of conda to have a timing comparison - canary-installs-mamba: - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.11'] - defaults: - run: - shell: bash -l {0} - steps: - - name: Setup python ${{ matrix.python-version }} conda environment - uses: mamba-org/setup-micromamba@v1 - with: - micromamba-version: '1.5.9-1' - environment-name: ab - create-args: >- - python=${{ matrix.python-version }} - activity-browser - - name: Environment info - run: | - micromamba list - micromamba env export - micromamba env export > env.yaml - - name: Upload final environment as artifact - uses: actions/upload-artifact@v4 - with: - name: env-${{ matrix.os }}-${{ matrix.python-version }}-mamba - path: env.yaml - - conda-micromamba-comparison: - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - needs: - - canary-installs - - canary-installs-mamba - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - - name: show files - run: | - ls -la - - name: correct yaml formatting - # add correct indentation to make diffing possible - uses: mikefarah/yq@master - with: - cmd: | - ls | grep mamba | while read d; do yq -i $d/env.yaml; done - - name: diff ubuntu - run: | - diff -u env-ubuntu-latest-3.11* || : - - name: diff windows - run: | - diff -u env-windows-latest-3.11* || : - - name: diff macos - run: | - diff -u env-macos-latest-3.11* || : - canary-installs-pip: timeout-minutes: 12 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ ubuntu-latest, windows-latest, macos-13 ] - python-version: [ '3.10' ] + os: [ubuntu-latest, windows-latest, macos-15, macos-latest] + py-version: ["3.10", "3.11", "3.12"] defaults: run: shell: bash -e {0} diff --git a/.github/workflows/just-release.yaml b/.github/workflows/just-release.yaml deleted file mode 100644 index 6a8921d7b..000000000 --- a/.github/workflows/just-release.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: force stable release -on: - workflow_dispatch: - - -jobs: - release: - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - steps: - - uses: actions/checkout@v3 - - name: Set up conda-build environment - uses: conda-incubator/setup-miniconda@v2 - with: - python-version: 3.11 - activate-environment: build - environment-file: .github/conda-envs/build.yml - - name: Build activity-browser stable - run: | - conda build recipe/ - - name: Upload to anaconda.org - run: | - anaconda -t ${{ secrets.CONDA_UPLOAD_TOKEN }} upload \ - /usr/share/miniconda/envs/build/conda-bld/noarch/*.tar.bz2 - - name: Update wiki - run: ./.github/scripts/update_wiki.sh "Automated documentation update for $GITHUB_REF_NAME" "${{ secrets.GITHUB_TOKEN }}" \ No newline at end of file diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml deleted file mode 100644 index befb50e2e..000000000 --- a/.github/workflows/main.yaml +++ /dev/null @@ -1,181 +0,0 @@ -name: tests and development release -on: - pull_request: - branches: - - main - - minor - push: - branches: - - main - - major - -jobs: - patch-test-environment: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Patch test environment dependencies - # This step adds the run requirements from the stable recipe to the test environment - uses: mikefarah/yq@master - with: - cmd: | - yq eval-all 'select(fi == 0).dependencies += select(fi == 1).requirements.run | select(fi == 0)' .github/conda-envs/test.yml recipe/meta.yaml > patched-environment.yml - - name: Show patched environment - run: cat patched-environment.yml - - name: Upload patched environment as artifact - uses: actions/upload-artifact@v4 - with: - name: patched-environment - path: patched-environment.yml - - tests: - runs-on: ${{ matrix.os }} - timeout-minutes: 12 - needs: patch-test-environment - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.10', '3.11'] - defaults: - run: - shell: bash -l {0} - steps: - - uses: actions/checkout@v3 - - name: Download patched test environment - uses: actions/download-artifact@v4 - with: - name: patched-environment - - name: Setup python ${{ matrix.python-version }} conda environment - uses: mamba-org/setup-micromamba@v1 - with: - micromamba-version: '1.5.9-1' - environment-name: test - environment-file: patched-environment.yml - create-args: >- - python=${{ matrix.python-version }} - - name: Environment info - run: | - micromamba list - micromamba env export - micromamba env export > env.yaml - - name: Upload final environment as artifact - uses: actions/upload-artifact@v4 - with: - name: env-${{ matrix.os }}-${{ matrix.python-version }} - path: env.yaml - - name: Install linux dependencies - if: ${{ matrix.os == 'ubuntu-latest' }} - # https://pytest-qt.readthedocs.io/en/latest/troubleshooting.html#github-actions - run: | - sudo apt install -y libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 \ - libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 \ - libxcb-xfixes0 xvfb x11-utils glibc-tools; - /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid \ - --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 \ - 1920x1200x24 -ac +extension GLX +render -noreset; - - name: Install coveralls and coverage - if: ${{ matrix.os == 'ubuntu-latest' }} - run: | - micromamba install -q -y coveralls=3.3.1 coverage pytest-cov - - name: Run linux tests - if: ${{ matrix.os == 'ubuntu-latest' }} - env: - QT_DEBUG_PLUGINS: 1 - run: | - catchsegv xvfb-run --auto-servernum pytest --cov=activity_browser --cov-report=; - - name: Run tests - if: ${{ matrix.os != 'ubuntu-latest' }} - run: | - pytest - - name: Upload coverage - if: ${{ matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest' }} - # https://github.com/lemurheavy/coveralls-public/issues/1435#issuecomment-763357004 - # https://coveralls-python.readthedocs.io/en/latest/usage/configuration.html#github-actions-support - # https://github.com/TheKevJames/coveralls-python/issues/252 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COVERALLS_SERVICE_NAME: github - run: | - coveralls - - deploy-development: - # Make sure to only run a deploy if all tests pass. - needs: - - tests - # And only on a push event, not a pull_request. - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - env: - PKG_NAME: "activity-browser-dev" - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: "0" - - name: Build and deploy 3.11 - uses: conda-incubator/setup-miniconda@v2 - with: - python-version: 3.11 - activate-environment: build - environment-file: .github/conda-envs/build.yml - - name: Export version - run: | - echo "VERSION=$(git describe --tags --always | cut -d- -f1,2 | sed 's/-/dev/')" >> $GITHUB_ENV - - name: Patch recipe with run requirements from stable - uses: mikefarah/yq@master - # Adds the run dependencies from the stable recipe to the dev recipe (inplace) - with: - cmd: | - yq eval-all -i 'select(fi == 0).requirements.run += select(fi == 1).requirements.run | select(fi == 0)' .github/dev-recipe/meta.yaml recipe/meta.yaml - - name: Show patched dev recipe - run: cat .github/dev-recipe/meta.yaml - - name: Build development package - run: | - conda build .github/dev-recipe/ - - name: Upload the activity-browser-dev package - run: | - anaconda -t ${{ secrets.CONDA_UPLOAD_TOKEN }} upload \ - /usr/share/miniconda/envs/build/conda-bld/noarch/*.conda - - deploy-beta: - # And only on a push event, not a pull_request. - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/major' }} - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - env: - PKG_NAME: "activity-browser-beta" - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: "0" - - name: Build and deploy 3.11 - uses: conda-incubator/setup-miniconda@v2 - with: - python-version: 3.11 - activate-environment: build - environment-file: .github/conda-envs/build.yml - - name: Export version - run: | - ID=$(git rev-list 2.11.0..HEAD --count) - VERSION="3.b.${ID}" - echo "VERSION=$VERSION" >> $GITHUB_ENV - - name: Patch recipe with run requirements from stable - uses: mikefarah/yq@master - # Adds the run dependencies from the stable recipe to the dev recipe (inplace) - with: - cmd: | - yq eval-all -i 'select(fi == 0).requirements.run += select(fi == 1).requirements.run | select(fi == 0)' .github/dev-recipe/meta.yaml recipe/meta.yaml - - name: Show patched dev recipe - run: cat .github/dev-recipe/meta.yaml - - name: Build beta package - run: | - conda build .github/dev-recipe/ - - name: Upload the activity-browser-dev package - run: | - anaconda -t ${{ secrets.CONDA_MRVISSCHER }} upload \ - /usr/share/miniconda/envs/build/conda-bld/noarch/*.conda diff --git a/.github/workflows/manual_update_wiki.yml b/.github/workflows/manual_update_wiki.yml deleted file mode 100644 index b6ddb4ed9..000000000 --- a/.github/workflows/manual_update_wiki.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: manual update of wiki - -on: - workflow_dispatch: - -jobs: - update_wiki: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: update wiki - run: ./.github/scripts/update_wiki.sh "Manual wiki update for $GITHUB_REF_NAME" "${{ secrets.GITHUB_TOKEN }}" diff --git a/activity_browser/ui/delegates/README.md b/activity_browser/ui/delegates/README.md index e2307d0cc..099aaa61e 100644 --- a/activity_browser/ui/delegates/README.md +++ b/activity_browser/ui/delegates/README.md @@ -14,48 +14,24 @@ In Qt's Model/View architecture, delegates handle: - **Validation** - Checking user input before accepting - **Decoration** - Adding icons, colors, or other visual elements -## Common Delegate Types - -### Numeric Delegates -- **Float delegate** - Editing decimal numbers with validation -- **Integer delegate** - Editing whole numbers with range limits -- **Percentage delegate** - Values with % formatting -- **Scientific notation delegate** - Large/small numbers - -### Text Delegates -- **String delegate** - Basic text with validation -- **Multiline delegate** - Text area for longer content -- **Formula delegate** - Parameter formula editing with syntax highlighting -- **Restricted text delegate** - Limited character sets - -### Selection Delegates -- **ComboBox delegate** - Drop-down selection from list -- **Checkbox delegate** - Boolean on/off values -- **Radio button delegate** - Mutually exclusive options -- **List delegate** - Multiple selections - -### Specialized Delegates -- **Unit delegate** - Unit selection with validation -- **Location delegate** - Geographic location picker -- **Database delegate** - Database selection -- **Activity delegate** - Activity selection with search - ## Usage Pattern -Assign delegates to specific columns: +Assign delegates to specific columns by defining them in the `defaultColumnDelegates` attribute of the `ABTreeView` class: ```python -from activity_browser.ui.delegates import FloatDelegate - -table = QTableView() -table.setItemDelegateForColumn(2, FloatDelegate(parent=table)) +class View(widgets.ABTreeView): + """ + A view that displays the exchanges in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + hovered_item (ExchangesItem): The item currently being hovered over. + """ + defaultColumnDelegates = { + "column_name": delegates.DelegateYouWantToUse, + } ``` -Or for all columns: - -```python -table.setItemDelegate(MyCustomDelegate(parent=table)) -``` ## Creating Custom Delegates @@ -160,63 +136,3 @@ When creating delegates: 8. **Consider performance** - Efficient for many cells 9. **Support keyboard** - Tab, Enter, Escape navigation 10. **Provide feedback** - Visual cues for invalid input - -## Common Patterns - -### Combobox Delegate -```python -class ComboBoxDelegate(QStyledItemDelegate): - def __init__(self, items, parent=None): - super().__init__(parent) - self.items = items - - def createEditor(self, parent, option, index): - editor = QComboBox(parent) - editor.addItems(self.items) - return editor -``` - -### Checkbox Delegate -```python -class CheckBoxDelegate(QStyledItemDelegate): - def paint(self, painter, option, index): - # Draw checkbox centered in cell - checked = index.data(Qt.DisplayRole) - # Custom painting code... -``` - -### Formula Delegate with Validation -```python -class FormulaDelegate(QStyledItemDelegate): - def setModelData(self, editor, model, index): - formula = editor.text() - if self.validate_formula(formula): - model.setData(index, formula) - else: - # Show error, keep editor open - pass -``` - -## Integration with Views - -Tables and trees use delegates automatically: - -```python -# Create view and model -view = QTableView() -model = QStandardItemModel() -view.setModel(model) - -# Assign delegates -view.setItemDelegateForColumn(0, StringDelegate()) -view.setItemDelegateForColumn(1, FloatDelegate()) -view.setItemDelegateForColumn(2, ComboBoxDelegate(["A", "B", "C"])) -``` - -## Performance - -For large tables: -- Keep `paint()` methods efficient -- Cache formatted values when possible -- Avoid complex editors for many cells -- Consider virtual scrolling with delegates diff --git a/activity_browser/ui/delegates/__init__.py b/activity_browser/ui/delegates/__init__.py index 9839d0548..c80635da9 100644 --- a/activity_browser/ui/delegates/__init__.py +++ b/activity_browser/ui/delegates/__init__.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- from .checkbox import CheckboxDelegate from .combobox import ComboBoxDelegate -from .database import DatabaseDelegate from .delete_button import DeleteButtonDelegate from .float import FloatDelegate from .json import JSONDelegate @@ -21,7 +20,6 @@ "AbsoluteAmountDelegate", "CheckboxDelegate", "ComboBoxDelegate", - "DatabaseDelegate", "DeleteButtonDelegate", "FloatDelegate", "JSONDelegate", diff --git a/activity_browser/ui/delegates/database.py b/activity_browser/ui/delegates/database.py deleted file mode 100644 index a80441a05..000000000 --- a/activity_browser/ui/delegates/database.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- -from bw2data import databases -from qtpy import QtCore, QtWidgets - - -class DatabaseDelegate(QtWidgets.QStyledItemDelegate): - """Nearly the same as the string delegate, but presents as - a combobox menu containing the databases of the current project. - """ - - def __init__(self, parent=None): - super().__init__(parent) - - def createEditor(self, parent, option, index): - editor = QtWidgets.QComboBox(parent) - editor.insertItems(0, databases.list) - return editor - - def setEditorData(self, editor: QtWidgets.QComboBox, index: QtCore.QModelIndex): - """Populate the editor with data if editing an existing field.""" - value = str(index.data(QtCore.Qt.DisplayRole)) - editor.setCurrentText(value) - - def setModelData( - self, - editor: QtWidgets.QComboBox, - model: QtCore.QAbstractItemModel, - index: QtCore.QModelIndex, - ): - """Take the editor, read the given value and set it in the model.""" - value = editor.currentText() - model.setData(index, value, QtCore.Qt.EditRole) diff --git a/activity_browser_beta/README.md b/activity_browser_beta/README.md deleted file mode 100644 index dae0996ee..000000000 --- a/activity_browser_beta/README.md +++ /dev/null @@ -1,154 +0,0 @@ -# activity_browser_beta - -Beta version package for Activity Browser. - -## Overview - -This is a separate package for the Activity Browser beta version (Version 3.0). It allows users to install and test beta features alongside the stable version without conflicts. - -## Purpose - -The beta package: -- **Parallel Installation** - Can coexist with stable version -- **Early Access** - Test new features before general release -- **Feedback** - Help improve Activity Browser through testing -- **Brightway 2.5** - Uses the latest Brightway version - -## Key Features in Beta - -- **Brightway 2.5 support** - Uses the latest Brightway framework -- **Multi-functionality** - Enhanced support for multi-functional processes -- **Improved performance** - Optimizations for large databases -- **New UI elements** - Updated interface components -- **Enhanced calculations** - Better calculation setup and management - -## Installation - -### From conda-forge -```bash -conda install -c conda-forge activity-browser-beta -``` - -### Alongside Stable Version -Both versions can be installed in different environments: - -```bash -# Stable version in one environment -conda create -n ab-stable -c conda-forge activity-browser - -# Beta version in another environment -conda create -n ab-beta -c conda-forge activity-browser-beta -``` - -## Running Beta - -Launch the beta version: -```bash -activity-browser-beta -``` - -Or from Python: -```python -from activity_browser_beta import run_activity_browser -run_activity_browser() -``` - -## Package Structure - -The `__init__.py` file likely re-exports or wraps the main activity_browser package with beta-specific configurations or modifications. - -## Data Compatibility - -### Projects -Beta may create or modify projects in ways incompatible with stable: -- **Separate projects** - Use different project names for beta testing -- **Backup first** - Always backup projects before testing beta -- **One-way migration** - Some changes may not be reversible - -### Databases -- Beta may support features not available in stable -- Database format changes may prevent opening in stable version -- Export/import may be needed to move data between versions - -## Reporting Issues - -Report beta issues on GitHub: -- Label issues with "beta" tag -- Specify you're using the beta version -- Include version number from Help → About -- Describe expected vs actual behavior -- Provide steps to reproduce - -## Transitioning to Stable - -When beta becomes stable: -1. Beta features are merged into main release -2. activity-browser-beta package is deprecated -3. Users migrate to standard activity-browser package -4. Projects created in beta work in new stable version - -## Development - -The beta package is typically: -- Built from a beta branch in the repository -- Tagged with beta version numbers (e.g., 3.0.0b1) -- Distributed via conda-forge beta channel -- Updated more frequently than stable - -## Feedback - -Help improve Activity Browser by: -- Testing new features -- Reporting bugs -- Suggesting improvements -- Comparing with stable version -- Documenting workflows - -Submit feedback: -- GitHub Issues: https://github.com/LCA-ActivityBrowser/activity-browser/issues -- Discussions: https://github.com/LCA-ActivityBrowser/activity-browser/discussions -- Email maintainers - -## Documentation - -Beta documentation is available at: -https://lca-activitybrowser.github.io/activity-browser/beta.html - -## Caution - -Beta software: -- **May have bugs** - Expect issues -- **May change** - Features may be modified or removed -- **May be unstable** - Crashes possible -- **Not for production** - Don't use for critical work -- **Data loss risk** - Always backup your data - -## Best Practices - -When testing beta: -1. **Create test projects** - Don't use real project data -2. **Backup everything** - Projects, databases, custom data -3. **Document issues** - Take notes on problems -4. **Compare with stable** - Verify behavior differences -5. **Separate environments** - Use dedicated conda environment -6. **Stay updated** - Check for beta updates regularly -7. **Read release notes** - Understand what changed -8. **Provide feedback** - Share your experience - -## Version Numbering - -Beta versions use pre-release identifiers: -- `3.0.0b1` - First beta -- `3.0.0b2` - Second beta -- `3.0.0rc1` - Release candidate -- `3.0.0` - Stable release - -## Support - -Beta support: -- Community support via GitHub Discussions -- Issue tracker for bug reports -- Limited email support -- Self-service documentation - -Remember: Beta software is experimental. Use at your own risk and always maintain backups of important data. diff --git a/activity_browser_beta/__init__.py b/activity_browser_beta/__init__.py deleted file mode 100644 index 2213aec91..000000000 --- a/activity_browser_beta/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -from importlib import metadata -from os import environ -from conda import cli -import requests - - -def check_ab_update() -> bool: - ab_url = "https://api.anaconda.org/package/mrvisscher/activity-browser-beta" - ab_response = requests.get(ab_url) - ab_current = metadata.version("activity_browser") - - if ab_response.status_code != 200: - print("Could not fetch latest activity browser beta version") - return False - - ab_latest = ab_response.json()['latest_version'].replace(".", "") - - print(f"activity_browser_beta: {ab_current} x {ab_latest}") - - if ab_current == "0.0.0" or ab_current == ab_latest: - return False - return True - - -def run(): - from activity_browser import run_activity_browser - print("Launching the Activity Browser") - run_activity_browser() - - -def run_activity_browser(): - print("Activity Browser 3 Beta Release") - print("______________________________________") - ab = check_ab_update() - print("______________________________________") - if ab and environ.get("CONDA_DEFAULT_ENV"): - print("Updating activity-browser-beta") - cli.main("update", "-c", "mrvisscher", "activity-browser-beta",) - run() From d4dc7360a5740ec6811880562607a8e23bff171d Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 13:37:48 +0100 Subject: [PATCH 229/267] Workflow readme --- .github/workflows/README.md | 192 ++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 .github/workflows/README.md diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 000000000..407cee17a --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,192 @@ +# GitHub Actions Workflows + +This document describes the GitHub Actions workflows used in the Activity Browser project. + +## Overview + +The Activity Browser project uses five GitHub Actions workflows to automate testing, deployment, and project management tasks: + +1. **Automated Testing** - Runs tests on every push and PR +2. **Canary Installation** - Daily installation checks to catch dependency issues +3. **Beta Deployment** - Publishes beta releases to PyPI and Anaconda +4. **Stable Release** - Creates releases and publishes to Anaconda +5. **Milestone Comments** - Automatically notifies users when issues are resolved in releases + +--- + +## 1. Automated Testing (`testing.yaml`) + +**Trigger:** Push or pull request to the `major` branch + +**Purpose:** Ensures code quality by running the test suite across multiple operating systems and Python versions. + +### Matrix Strategy +- **Operating Systems:** Ubuntu (latest), Windows (latest), macOS 15, macOS (latest) +- **Python Versions:** 3.10, 3.11, 3.12 +- **Total combinations:** 12 test runs per trigger + +### Steps +1. Checkout code +2. Set up Python for the specified version +3. Install Qt libraries (Linux only) +4. Update pip, setuptools, and wheel +5. Install package with testing dependencies: `pip install .[testing]` +6. Run pytest with minimal output: `pytest -s --no-header --no-summary -q` + +### Environment +- Sets `QT_QPA_PLATFORM=offscreen` for headless GUI testing +- Uses `fail-fast: false` to run all combinations even if some fail + +--- + +## 2. Canary Installation (`install-canary.yaml`) + +**Trigger:** Scheduled daily at 7:00 AM UTC (cron: `0 7 * * *`) + +**Purpose:** Proactively detects dependency issues by performing fresh installations of Activity Browser from PyPI daily. + +### Matrix Strategy +- **Operating Systems:** Ubuntu (latest), Windows (latest), macOS 15, macOS (latest) +- **Python Versions:** 3.10, 3.11, 3.12 +- **Timeout:** 12 minutes per job + +### Steps +1. Checkout code +2. Set up Python +3. Install activity-browser from PyPI (not from source) +4. Generate environment info with `pip freeze` +5. Upload frozen requirements as artifact for each OS/Python combination + +### Notes +- Uses `bash -e {0}` shell to exit on error +- Helps catch breaking changes in dependencies before users encounter them +- Artifacts show exact dependency versions that successfully installed + +--- + +## 3. Beta Deployment (`python-package-deploy.yml`) + +**Trigger:** Push to `beta` branch or any tag + +**Purpose:** Publishes beta versions to PyPI (test and production) and Anaconda Cloud. + +### Version Scheme +- Beta version format: `3.0.0b` where N is the commit count since commit `199b6c3` +- Calculated dynamically: `git rev-list 199b6c3..HEAD --count` + +### Steps +1. Checkout with full git history (`fetch-depth: "0"`) +2. Calculate and set version number +3. Set up Python 3.11 +4. Install `build` package +5. Build wheel and source distribution +6. **PyPI Publishing:** + - Publish to Test PyPI (with `skip-existing: true`) + - Publish to production PyPI +7. **Conda Publishing:** + - Set up Conda environment from `.github/conda-envs/build.yml` + - Build Conda package: `conda build -c conda-forge -c cmutel ./recipe/` + - Upload to Anaconda Cloud using `CONDA_LCA` secret token + +### Permissions +- Requires `id-token: write` for PyPI trusted publishing + +--- + +## 4. Stable Release (`release.yaml`) + +**Trigger:** Push of any git tag + +**Purpose:** Creates GitHub releases with auto-generated changelogs and publishes stable versions to Anaconda. + +### Steps +1. Checkout code +2. **Generate Changelog:** + - Uses `mikepenz/release-changelog-builder-action@v4` + - Configuration from `.github/changelog-configuration.json` + - Builds changelog from PRs with labels +3. **Create GitHub Release:** + - Uses `ncipollo/release-action@v1` + - Includes generated changelog as release notes + - Targets `main` branch commit +4. **Build and Upload Conda Package:** + - Set up Conda environment (Python 3.11) + - Build with `conda build recipe/` + - Upload to Anaconda using `CONDA_UPLOAD_TOKEN` secret +5. **Update Wiki:** + - Runs `.github/scripts/update_wiki.sh` to automatically update documentation + +### Notes +- Only runs on tagged commits (version releases) +- Creates public GitHub releases visible to users +- Updates project wiki documentation automatically + +--- + +## 5. Milestone Comments (`comment-milestoned-issues.yaml`) + +**Trigger:** When a milestone is closed + +**Purpose:** Automatically notifies users on closed issues when their issue has been implemented in a release. + +### Steps +1. Uses `actions/github-script@v5` to run JavaScript automation +2. Gets milestone number and title from the event +3. Lists all issues associated with the milestone +4. For each closed issue (not PRs): + - Posts a comment with: + - Link to the new release + - Instructions to update Activity Browser + - Link to subscribe to the updates mailing list + - Bot disclaimer + +### Comment Template +The bot posts a formatted note: +- Informs that the issue is implemented in version X +- Provides update instructions +- Offers subscription to updates mailing list (brightway.groups.io) +- Includes bot identification + +--- + +## Workflow Dependencies + +### Secrets Required +- `GITHUB_TOKEN` - Automatically provided by GitHub Actions +- `CONDA_LCA` - Anaconda upload token for beta releases +- `CONDA_UPLOAD_TOKEN` - Anaconda upload token for stable releases + +### Configuration Files +- `.github/conda-envs/build.yml` - Conda environment for building packages +- `.github/changelog-configuration.json` - Changelog generation configuration +- `.github/scripts/update_wiki.sh` - Wiki update script +- `recipe/meta.yaml` - Conda package recipe +- `pyproject.toml` - Python package configuration + +--- + +## Development Notes + +### Running Tests Locally +To run the same tests that CI runs: +```bash +pip install .[testing] +pytest -s --no-header --no-summary -q +``` + +### Testing Matrix Changes +When modifying the test matrix (OS or Python versions): +- Update both `testing.yaml` and `install-canary.yaml` to keep them in sync +- Consider the maintenance burden of additional combinations +- Current support: Python 3.10-3.12, Ubuntu/Windows/macOS + +### Release Process +1. **Beta release:** Push to `beta` branch → Auto-publishes beta version +2. **Stable release:** Create and push a tag → Creates GitHub release and publishes to Anaconda +3. **Close milestone:** When closing a milestone → Users get notified automatically + +### Monitoring +- Check daily canary runs to catch dependency issues +- Review failed test runs in PR checks before merging +- Monitor PyPI and Anaconda Cloud for successful uploads + From d62ca0b50a3e163b111ced5e686b192b8329e1b2 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 13:40:03 +0100 Subject: [PATCH 230/267] fixed setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 63ba3b2b9..407e7b459 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ setup( version=version, - packages=["activity_browser", "activity_browser_beta"], + packages=["activity_browser"], license=open("LICENSE.txt").read(), include_package_data=True, ) From f6eb603f9fe931d97307656a3faeb05e8f28734b Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 14:28:58 +0100 Subject: [PATCH 231/267] First go at redoing readme's --- activity_browser/README.md | 19 +--- activity_browser/app/README.md | 17 ++-- activity_browser/app/actions/README.md | 28 +----- activity_browser/app/dialogs/README.md | 117 ++----------------------- activity_browser/app/pages/README.md | 70 ++++----------- 5 files changed, 32 insertions(+), 219 deletions(-) diff --git a/activity_browser/README.md b/activity_browser/README.md index 9eb2b3f5b..6339d5cd5 100644 --- a/activity_browser/README.md +++ b/activity_browser/README.md @@ -29,22 +29,7 @@ The application can be started in multiple ways: All entry points lead to `activity_browser.__main__:run_activity_browser`. -## Architecture - -The application follows an MVC-like pattern with: -- **Global signals** (`activity_browser.app.signals`) - Event bus for cross-component communication -- **Deferred imports** - Heavy modules are loaded in background threads during startup -- **Actions pattern** - UI operations encapsulated in `app/actions/` with a base class pattern - -## Dependencies - -Main dependencies include: -- **PySide6** (via qtpy) - Qt bindings for the GUI -- **Brightway2** ecosystem (bw2data, bw2calc, bw2analyzer, bw2io) - LCA calculation engine -- **loguru** - Logging framework - ## Development Notes -- Avoid top-level imports of heavy modules (PySide6, bw2data) to keep tests fast -- Use project signals for cross-component communication instead of direct function calls -- Global shortcuts are registered via `@application.global_shortcut` decorator +- See `CONTRIBUTING.md` for guidelines on contributing to the project +- Check out the Development notes specific to each submodule for more details on implementation diff --git a/activity_browser/app/README.md b/activity_browser/app/README.md index 785b676f7..7e24d9129 100644 --- a/activity_browser/app/README.md +++ b/activity_browser/app/README.md @@ -31,9 +31,9 @@ This module orchestrates the main application components including the main wind The app module creates and wires together the core application components: 1. **Application** (`ABApplication`) - Qt application instance with global shortcut management -2. **Signals** (`ABSignals`) - Project-wide event bus for cross-component communication +2. **Signals** (`ABSignals`) - Project-wide event bus for model to UI communication 3. **Main Window** (`MainWindow`) - Main application window with pages and panes -4. **Actions** - Command pattern implementation for menu items and toolbar actions +4. **Actions** - Command pattern implementation for menu items and toolbar actions. Modifying Brightway2 happens here. 5. **Pages** - Content area widgets for different application views 6. **Panes** - Dock-able side panels @@ -59,12 +59,9 @@ app.metadata # Metadata store app.main_window # Main window ``` -## Actions Pattern +## Development Notes -Actions encapsulate user commands and are defined in the `actions/` subdirectory. Each action: -- Inherits from `ABAction` base class -- Defines icon, text, tooltip -- Implements a `run()` static method -- Can be converted to QAction or QPushButton - -See `actions/base.py` for the action framework. +- See `CONTRIBUTING.md` for guidelines on contributing to the project +- This module is the place to add components that depend on the application having been initialized (e.g., actions, panes) + - If the logic you want to add can only depend on brightway2, consider placing it in the `bwutils` submodule instead + - If the widget you want to add does not depend on the application, consider placing it in the `ui` submodule instead diff --git a/activity_browser/app/actions/README.md b/activity_browser/app/actions/README.md index a6f513443..028d435c3 100644 --- a/activity_browser/app/actions/README.md +++ b/activity_browser/app/actions/README.md @@ -17,16 +17,6 @@ This directory contains all user-triggered actions in Activity Browser. Each act - **`project/`** - Project-level operations - **`tools/`** - Various tools and utilities accessible via actions -## Key Files - -- **`base.py`** - `ABAction` base class that all actions inherit from -- **`metadatastore_open.py`** - Action to open the metadata store dialog -- **`migrations_install.py`** - Database migration actions -- **`node_select_open.py`** - Node selection dialog action -- **`pyside_upgrade.py`** - PySide upgrade helper action -- **`save_parameters_to_excel.py`** - Export parameters to Excel -- **`settings_wizard_open.py`** - Settings wizard dialog action - ## Action Pattern All actions follow a consistent pattern defined in `base.py`: @@ -48,7 +38,7 @@ class MyAction(ABAction): 1. **Declarative** - Icon, text, and tooltip defined as class attributes 2. **Callable arguments** - Arguments can be functions (evaluated at runtime) 3. **Qt integration** - Can be converted to QAction or QPushButton -4. **Exception handling** - Optional decorator for error dialogs +4. **Exception handling** - Always add `@exception_dialogs` decorator for user-facing errors 5. **Flexible invocation** - Triggered from menus, buttons, shortcuts ## Usage @@ -99,18 +89,4 @@ When adding new actions: ## Signal Integration -Actions should emit signals when they modify application state: - -```python -from activity_browser import app - -class MyAction(ABAction): - @staticmethod - def run(): - # Perform operation - ... - # Emit signal - app.signals.database_changed.emit() -``` - -This ensures other components can react to state changes without tight coupling. +**Actions should not emit signals themselves** That being said, actions should only emit signals when they modify state in a way that Brightway2 does not automatically notify the UI about. See e.g. parameter_group_delete.py for an example of emitting a signal after deleting a parameter group. diff --git a/activity_browser/app/dialogs/README.md b/activity_browser/app/dialogs/README.md index 639148e6b..95087a8d4 100644 --- a/activity_browser/app/dialogs/README.md +++ b/activity_browser/app/dialogs/README.md @@ -4,117 +4,10 @@ Dialog windows for user interactions throughout Activity Browser. ## Overview -This directory contains modal and non-modal dialog windows used for various user interactions such as data entry, configuration, selection, and information display. +This directory contains modal and non-modal dialog windows used for various user interactions such as data entry, configuration, selection, and information display. Dialogs in the app directory are there because they are tightly integrated with Brightway2 or depend on the application for other reasons. -## Purpose +- Generally, action specific dialogs are located alongside the corresponding action in the `actions/` directory. +- Dialogs than can be applied more widely and are not intimately tied with either actions or Brightway2 are located in the `ui/dialogs/` directory. +- Only if the above two locations are not appropriate should a dialog be placed here. -Dialogs provide focused interfaces for: -- User input and data entry -- Configuration and settings -- Selection of items (activities, methods, databases) -- Information display and confirmations -- Multi-step workflows (see also `ui/wizards/`) - -## Common Dialog Types - -### Input Dialogs -- Text input fields -- Numeric value entry -- Date/time selection -- Multi-line text editing - -### Selection Dialogs -- List/tree item selection -- Database/activity pickers -- Method selection -- File/directory choosers - -### Configuration Dialogs -- Settings editors -- Preference panels -- Option configuration - -### Information Dialogs -- Progress indicators -- Status messages -- Warnings and errors -- About/help information - -## Design Guidelines - -Dialogs in Activity Browser should: - -1. **Be modal when appropriate** - Block parent window for critical decisions -2. **Provide clear actions** - OK/Cancel, Accept/Reject, or custom actions -3. **Validate input** - Check data before accepting -4. **Give feedback** - Show errors, warnings, progress -5. **Be responsive** - Use threading for long operations -6. **Follow Qt conventions** - Inherit from QDialog, use standard buttons - -## Usage Pattern - -```python -from qtpy.QtWidgets import QDialog, QDialogButtonBox - -class MyDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setup_ui() - - def setup_ui(self): - # Build dialog UI - pass - - def accept(self): - # Validate and process input - if self.validate(): - super().accept() -``` - -## Integration with Actions - -Dialogs are typically opened via actions: - -```python -from activity_browser.app.actions.base import ABAction - -class OpenMyDialog(ABAction): - @staticmethod - def run(): - dialog = MyDialog() - if dialog.exec_() == QDialog.Accepted: - # Process result - pass -``` - -## Threading Considerations - -Long-running operations in dialogs should use worker threads: - -```python -from activity_browser.ui.core.threading import ABThread - -class MyDialog(QDialog): - def perform_long_operation(self): - worker = ABThread(self.expensive_task) - worker.finished.connect(self.on_complete) - worker.start() -``` - -## Signal Emission - -Dialogs should emit signals to notify the application of changes: - -```python -from activity_browser import app - -class MyDialog(QDialog): - def accept(self): - # Save changes - self.save_data() - # Notify application - app.signals.data_changed.emit() - super().accept() -``` - -This ensures the rest of the application can react to changes made in dialogs without tight coupling. +What qualifies to be put in this directory is somewhat subjective, but the guiding principle is that these dialogs are core to the functioning of Activity Browser and are not easily reusable outside of it. \ No newline at end of file diff --git a/activity_browser/app/pages/README.md b/activity_browser/app/pages/README.md index 8802c4c21..fda37b35c 100644 --- a/activity_browser/app/pages/README.md +++ b/activity_browser/app/pages/README.md @@ -18,67 +18,25 @@ This directory contains the primary content pages that users interact with in Ac ## Key Files - **`welcome.py`** - Welcome page shown when no project is open or on first launch -- **`metadatastore.py`** - Metadata management page +- **`metadatastore.py`** - Metadata view page (DEBUG only) -## Page Architecture +## Two types of pages -Pages inherit from `AbstractPage` (in `ui/widgets/abstract_page.py`) which provides: -- Consistent layout structure -- Signal connections -- Toolbar integration -- State management - -## Page Lifecycle - -1. **Creation** - Page is instantiated and added to the main window -2. **Display** - User navigates to the page (shown in central widget) -3. **Updates** - Page responds to signals and refreshes data -4. **Interaction** - User performs actions within the page -5. **Persistence** - Page state may be saved when switching away - -## Common Page Features - -### Toolbars -Most pages include a toolbar with actions: -```python -self.toolbar = QToolBar() -self.toolbar.addAction(MyAction.get_QAction()) -``` - -### Data Display -Pages typically contain: -- Tables showing lists of items -- Tree views for hierarchical data -- Charts and plots for visualizations -- Forms for data entry - -### Signal Handling -Pages connect to global signals: -```python -from activity_browser import app - -app.signals.database_changed.connect(self.update_content) -``` - -## Page Navigation - -Users navigate between pages via: -- Menu bar (View menu) -- Toolbar buttons -- Context menus -- Actions triggered by events (e.g., double-click activity → show details) +1. **Base pages** - Pages that are initialized once and remain in memory (e.g., Welcome Screen, Parameters, Settings). + - They maintain their state and reload data on project switches. + - Hidden/shown based on user actions or preferences in the settings. + - Defined in `__init__.py`. +2. **Dynamic pages** - Pages that show specific data and are opened as such by the user (e.g. Activity Details, LCA results). + - Created on demand and closed when no longer needed. + - Multiple instances can exist (e.g., multiple activity detail pages) and will be grouped. ## Development Guidelines When creating new pages: -1. **Inherit from AbstractPage** - Use the base class for consistency -2. **Set page title** - Provide a clear, descriptive title -3. **Create toolbar** - Add relevant actions for the page -4. **Connect signals** - Listen for relevant application events -5. **Handle updates** - Refresh data when underlying state changes -6. **Manage state** - Save/restore page state when appropriate -7. **Use threading** - Long operations should not block the UI +- Should follow the `PageNamePage` naming convention. +- Set a unique ObjectName for identification. +- Set appropriate tab titles using `setWindowTitle()`. ## Subdirectory Details @@ -111,6 +69,8 @@ Display LCA calculation results: - Export options ### `parameters/` +[BASE PAGE] + Manage parameters and scenarios: - Project parameters - Database parameters @@ -119,6 +79,8 @@ Manage parameters and scenarios: - Scenario management ### `settings/` +[BASE PAGE] + Application configuration: - General preferences - Project settings From 1422c921fae7cc43579ddab73add30f2fbd9f6e1 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Wed, 10 Dec 2025 14:30:59 +0100 Subject: [PATCH 232/267] Exchange icons push --- .../static/icons/exchanges/link.png | Bin 0 -> 31393 bytes .../static/icons/exchanges/relink.png | Bin 0 -> 32995 bytes .../static/icons/exchanges/unlink.png | Bin 0 -> 34142 bytes docs/img.png | Bin 0 -> 576 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 activity_browser/static/icons/exchanges/link.png create mode 100644 activity_browser/static/icons/exchanges/relink.png create mode 100644 activity_browser/static/icons/exchanges/unlink.png create mode 100644 docs/img.png diff --git a/activity_browser/static/icons/exchanges/link.png b/activity_browser/static/icons/exchanges/link.png new file mode 100644 index 0000000000000000000000000000000000000000..7742c7dbfda87df57e5704803d60abac87eb5733 GIT binary patch literal 31393 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
w&qgc~o{km?4j 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 literal 0 HcmV?d00001 diff --git a/activity_browser/static/icons/exchanges/unlink.png b/activity_browser/static/icons/exchanges/unlink.png new file mode 100644 index 0000000000000000000000000000000000000000..6d681334f621d6b740be25830640987117d43533 GIT binary patch literal 34142 zcmXtA1yEGq+uo&1Qb1Y+ge4?@NP~cYq_hYKNJ)1{?W%N&pfre-bSWS$DM-4Mf^_%N zx%-{;n8E zc2_g;fYX%`N)|dkTA+e$`1_uGk3BG{gVw(Fm0z;?SvipjVyyqdi?}i$juD_gqB{vPkLcF{(d#(qBX<`{AAP|we_S%S`s3xZuMl7 zw@+1D2NoeWI-$&WLnX}AU*1A2K>95O>mI@82=?nJ7X=`DXV>U@M@L6-(-z^?EvtVQ z5|Fo_p_XE#rKNR7e6Bem+&B_Y1viNdEyy#R{ZGjUVFtX%zus|aJT05OdC0I00egQk z4>2$_Gz1%uOcuehEGtJ^KgZNFT7n)i``b{lY&rpRzy(g>59nuQ%fk~ z(#IU$Tv2h)1>7C-@9u$*#296j)g@`$3k(w6jzeTR0@%Aa6eFd<3z+*7AgUeC0tvTD zm9;!r?rSYKtC}yJh2*|xYMJIC=f$4(eQ+%9P zZ4+Z-Wz_+qz7hXc&JuE+X<$#&TrJY;{|P-SpQWf(^~Uj6BSK|7;Xi2|ļ-*YSY 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 Date: Wed, 10 Dec 2025 15:00:18 +0100 Subject: [PATCH 233/267] Readmes --- activity_browser/app/panes/README.md | 64 +----- activity_browser/bwutils/README.md | 7 +- .../ecoinvent_biosphere_versions/README.md | 138 ------------- activity_browser/bwutils/io/README.md | 116 ----------- activity_browser/bwutils/metadata/README.md | 109 ++--------- .../bwutils/searchengine/README.md | 179 ----------------- .../bwutils/superstructure/README.md | 179 ----------------- activity_browser/mod/bw2analyzer/README.md | 185 ------------------ 8 files changed, 22 insertions(+), 955 deletions(-) delete mode 100644 activity_browser/bwutils/ecoinvent_biosphere_versions/README.md delete mode 100644 activity_browser/bwutils/io/README.md delete mode 100644 activity_browser/bwutils/searchengine/README.md delete mode 100644 activity_browser/bwutils/superstructure/README.md delete mode 100644 activity_browser/mod/bw2analyzer/README.md diff --git a/activity_browser/app/panes/README.md b/activity_browser/app/panes/README.md index 35c959916..6b6eb305c 100644 --- a/activity_browser/app/panes/README.md +++ b/activity_browser/app/panes/README.md @@ -22,23 +22,11 @@ Panes inherit from `AbstractPane` (in `ui/widgets/abstract_pane.py`) which provi - Signal connections - State persistence (dock position, visibility) -## Common Pane Types - -### Navigation Panes -- **Database browser** - Tree view of available databases -- **Activity browser** - Search and browse activities -- **Method browser** - Browse impact assessment methods -- **Project browser** - List of Brightway projects - -### Information Panes -- **Details panel** - Show details of selected items -- **Properties** - Display item properties and metadata -- **History** - Recent actions or visited items - -### Tool Panes -- **Quick calculations** - Run simple calculations -- **Search** - Global search interface -- **Console** - Python console for advanced users +## Existing Panes +- **Databases Pane** - View of available databases +- **Database Products Pane** - Search and browse product-type nodes within a database +- **Impact Categories Pane** - Browse impact assessment methods +- **Calculation Setups Pane** - List of Calculation Setups ## Pane Features @@ -65,50 +53,16 @@ from activity_browser.ui.widgets import AbstractPane class MyPane(AbstractPane): def __init__(self, parent=None): super().__init__(parent) - self.setup_ui() - - def setup_ui(self): - # Build pane content - pass -``` - -## Integration with Main Window - -Panes are added to the main window as dock widgets: - -```python -from activity_browser import app - -pane = MyPane() -app.main_window.addDockWidget(Qt.LeftDockWidgetArea, pane) -``` - -## Signal Communication - -Panes communicate with other components via signals: - -```python -from activity_browser import app - -class MyPane(AbstractPane): - def on_item_selected(self, item): - # Emit signal for other components - app.signals.item_selected.emit(item) ``` ## Development Guidelines When creating new panes: -1. **Inherit from AbstractPane** - Use the base class for consistency -2. **Set pane title** - Provide a clear, descriptive title -3. **Keep focused** - Each pane should have a single, clear purpose -4. **Connect signals** - Listen for and emit relevant signals -5. **Handle updates** - Refresh when underlying data changes -6. **Support search/filter** - Allow users to find items quickly -7. **Provide context menus** - Right-click actions for items -8. **Make it closeable** - Users should be able to hide panes -9. **Support keyboard navigation** - Enable keyboard shortcuts +- **Inherit from AbstractPane** - Use the base class for consistency +- **Set pane title** - Use the standard `PaneNamePane` naming convention to set the title automatically +- **Base panes** - Add base panes to `__init__.py` in this directory so they are loaded by the main window on project change. + ## Visibility Control diff --git a/activity_browser/bwutils/README.md b/activity_browser/bwutils/README.md index 6410b6955..acbd57bf8 100644 --- a/activity_browser/bwutils/README.md +++ b/activity_browser/bwutils/README.md @@ -10,8 +10,8 @@ This module provides a collection of generic methods and utilities that wrap and - **`ecoinvent_biosphere_versions/`** - Ecoinvent biosphere database version mappings - **`io/`** - Import/export operations for data interchange -- **`metadata/`** - Metadata management for activities and databases -- **`searchengine/`** - Search functionality for activities and exchanges +- **`metadata/`** - Metadata loading and caching for quick access +- **`searchengine/`** - Fuzzy search functionality for dataframes - **`superstructure/`** - Superstructure scenario analysis tools ## Key Files @@ -46,8 +46,6 @@ Import utilities as needed throughout the application: ```python from activity_browser.bwutils import commontasks -from activity_browser.bwutils.errors import ABError -from activity_browser.bwutils.manager import ABManager ``` ## Design Principle @@ -56,4 +54,3 @@ Keep utilities generic and reusable. These functions should: - Work with Brightway2 data structures - Be independent of UI components - Be testable without requiring a GUI -- Emit signals when state changes (via `activity_browser.app.signals`) diff --git a/activity_browser/bwutils/ecoinvent_biosphere_versions/README.md b/activity_browser/bwutils/ecoinvent_biosphere_versions/README.md deleted file mode 100644 index 0370ec8b7..000000000 --- a/activity_browser/bwutils/ecoinvent_biosphere_versions/README.md +++ /dev/null @@ -1,138 +0,0 @@ -# ecoinvent_biosphere_versions - -Ecoinvent biosphere database version mappings and compatibility information. - -## Overview - -This directory manages compatibility between different versions of ecoinvent databases and their corresponding biosphere flows. It ensures that biosphere flows are correctly linked when importing ecoinvent databases. - -## Key Files - -- **`compatible_ei_versions.txt`** - List of compatible ecoinvent versions -- **`ecospold2biosphereimporter.py`** - Custom importer for ecospold2 biosphere flows -- **`legacy_biosphere/`** - Legacy biosphere flow definitions for older ecoinvent versions - -## Purpose - -Ecoinvent databases come in different versions (e.g., 3.6, 3.7, 3.8, 3.9), and each version may have: -- Different biosphere flow definitions -- Updated flow names or properties -- New or deprecated flows -- Different CAS numbers or UUIDs - -This module ensures: -- **Correct linking** - Activities link to the right biosphere flows -- **Version compatibility** - Handle differences between ecoinvent versions -- **Migration support** - Update flows when upgrading ecoinvent versions -- **Legacy support** - Work with older databases - -## Compatible Versions - -The `compatible_ei_versions.txt` file lists ecoinvent versions that Activity Browser supports. Typically includes: -- ecoinvent 3.5 -- ecoinvent 3.6 -- ecoinvent 3.7 -- ecoinvent 3.8 -- ecoinvent 3.9 -- ecoinvent 3.10 - -## Biosphere Flow Linking - -When importing an ecoinvent database: - -1. **Detect version** - Identify ecoinvent version from metadata -2. **Load biosphere** - Use appropriate biosphere flow set -3. **Link flows** - Match elementary flows to biosphere database -4. **Handle mismatches** - Resolve or report linking issues - -## ecospold2biosphereimporter.py - -Custom importer that: -- Extends brightway2-io's ecospold2 importer -- Handles version-specific biosphere flows -- Applies migration strategies -- Fixes known issues in ecoinvent data - -## Legacy Biosphere - -The `legacy_biosphere/` directory contains: -- Flow definitions from older ecoinvent versions -- Migration mappings between versions -- Deprecated flow information -- Compatibility patches - -## Usage Pattern - -Typically used automatically during import: - -```python -from activity_browser.bwutils.importers import import_ecoinvent - -# Import will automatically handle biosphere version -import_ecoinvent( - filepath="ecoinvent_38_cutoff.ecospold", - database_name="ecoinvent 3.8" -) -``` - -## Version Detection - -Ecoinvent version is detected from: -- File metadata in ecospold files -- Database description field -- Version field in activity metadata -- Directory/file naming patterns - -## Handling Version Mismatches - -When biosphere versions don't match: - -1. **Automatic migration** - Update flow references -2. **Manual linking** - User selects correct flows -3. **Warning messages** - Inform user of issues -4. **Fallback matching** - Use fuzzy matching as last resort - -## Development Guidelines - -When adding support for new ecoinvent versions: - -1. **Update compatible versions** - Add to `compatible_ei_versions.txt` -2. **Test import** - Verify all flows link correctly -3. **Document changes** - Note any flow changes from previous version -4. **Add migrations** - If flows changed, add migration strategies -5. **Update tests** - Add test for new version - -## Common Issues - -### Unlinked Flows -If flows don't link: -- Check ecoinvent version detection -- Verify biosphere database version -- Review flow names for changes -- Check for typos or encoding issues - -### Wrong Flow Versions -If using wrong flow set: -- Verify version detection logic -- Check metadata parsing -- Update version mapping - -### Missing Flows -If flows are missing: -- Check if flows were added in newer ecoinvent version -- Verify biosphere database is up-to-date -- Add manual definitions if needed - -## Maintenance - -Keep up-to-date with: -- New ecoinvent releases -- Biosphere flow changes -- Brightway2 updates -- User-reported issues - -## Resources - -- [ecoinvent website](https://ecoinvent.org/) -- [ecoinvent version history](https://ecoinvent.org/the-ecoinvent-database/data-releases/) -- [brightway2-io documentation](https://docs.brightway.dev/projects/brightway2-io/) diff --git a/activity_browser/bwutils/io/README.md b/activity_browser/bwutils/io/README.md deleted file mode 100644 index 75814e1e0..000000000 --- a/activity_browser/bwutils/io/README.md +++ /dev/null @@ -1,116 +0,0 @@ -# io - -Import and export operations for LCA data interchange. - -## Overview - -This directory handles import and export operations for various LCA data formats, enabling data exchange between Activity Browser, Brightway2, and other LCA tools. - -## Purpose - -The io module provides: -- **Import** - Bring data from external sources into Brightway2 -- **Export** - Save Brightway2 data to various formats -- **Conversion** - Transform between different LCA data formats -- **Validation** - Check data integrity during import/export - -## Supported Formats - -### Import Formats -- **ecospold1/2** - Ecoinvent XML formats -- **SimaPro CSV** - SimaPro export format -- **Excel** - Custom Excel templates -- **JSON-LD** - Linked data format -- **ILCD** - International Reference Life Cycle Data System -- **Brightway2 packages** - BW2Package format - -### Export Formats -- **Excel** - Various Excel export templates -- **CSV** - Comma-separated values -- **Brightway2 packages** - For backup and sharing -- **SimaPro CSV** - For use in SimaPro - -## Architecture - -Import/export operations typically follow this pattern: - -1. **Selection** - User selects file(s) to import or export location -2. **Configuration** - Set options (database name, linking strategies, etc.) -3. **Processing** - Parse/transform data (often in a worker thread) -4. **Validation** - Check for errors or warnings -5. **Completion** - Write to database or save to file -6. **Feedback** - Report success, errors, or warnings to user - -## Threading - -Import/export operations use worker threads to avoid blocking the UI: - -```python -from activity_browser.ui.core.threading import ABThread - -worker = ABThread(import_function, args) -worker.finished.connect(on_complete) -worker.start() -``` - -## Error Handling - -Robust error handling is critical: -- Validate data before processing -- Provide clear error messages -- Allow partial success when possible -- Log errors for debugging -- Don't lose user data on failure - -## Usage Pattern - -```python -from activity_browser.bwutils.io import import_ecospold2 - -# Import with progress tracking -result = import_ecospold2( - filepath="data.ecospold", - database_name="my_database", - progress_callback=update_progress -) -``` - -## Integration with Actions - -Import/export is typically triggered via actions: - -```python -from activity_browser.app.actions.base import ABAction - -class ImportEcospold(ABAction): - @staticmethod - def run(): - # File selection dialog - filepath = get_file_path() - # Import in background thread - import_data(filepath) -``` - -## Development Guidelines - -When adding new import/export functionality: - -1. **Use worker threads** - Don't block the UI -2. **Provide progress updates** - Keep user informed -3. **Validate data** - Check before committing -4. **Handle errors gracefully** - Give helpful error messages -5. **Support cancellation** - Allow user to abort long operations -6. **Log operations** - Help with debugging -7. **Test with real data** - Use actual LCA databases for testing -8. **Document format specifics** - Note any format peculiarities or limitations - -## Strategies - -Import operations often use strategies to link exchanges: -- Match by name and location -- Match by code/UUID -- Match by CAS number -- Fuzzy matching -- Manual linking fallback - -See `bwutils/strategies.py` for strategy implementations. diff --git a/activity_browser/bwutils/metadata/README.md b/activity_browser/bwutils/metadata/README.md index ea3d2e2b2..e3ac4b93f 100644 --- a/activity_browser/bwutils/metadata/README.md +++ b/activity_browser/bwutils/metadata/README.md @@ -4,47 +4,24 @@ Metadata management for activities, databases, and methods. ## Overview -This directory handles storage, retrieval, and management of metadata associated with LCA data in Activity Browser. Metadata provides additional context and information beyond what Brightway2 stores natively. +This directory handles storage, retrieval, and management of metadata associated with LCI data in Activity Browser. The MetaDataStore provides quick access to reading node data. ## Purpose Metadata management provides: -- **Extended information** - Additional fields beyond Brightway2 schema -- **User annotations** - Comments, tags, custom fields -- **Workflow tracking** - Modification history, authorship -- **Search enhancement** - Additional searchable attributes -- **Classification** - Custom categorization schemes +- **In memory** - Quicker access to ranges of nodes +- **Unpacked data blob** - Unpack the data blob from the sqlite for quick access +- **Search enhancement** - Fuzzy search capabilities on metadata fields ## Metadata Types -### Activity Metadata -- Custom descriptions -- Data quality assessments -- Pedigree matrices -- User comments -- Modification timestamps -- Authorship information - -### Database Metadata -- Database descriptions -- Source information -- Version tracking -- Import history -- Licensing information - -### Method Metadata -- Method descriptions -- Methodological choices -- References and sources -- Uncertainty information +See `fields.py` for defined metadata fields and schemas. Common types include: +- **code** - Activity codes +- **name** - Activity names +- **synonyms** - Alternative names ## Storage - -Metadata is stored separately from Brightway2's native storage: -- JSON files in user data directory -- Keyed by activity/database/method identifiers -- Persisted across sessions -- Backed up with projects +Metadata is cached separately from Brightway2's native storage to allow faster access and searching. It is stored as a pickle on each flush. ## MetaDataStore @@ -67,75 +44,11 @@ metadata.update_activity_metadata(activity_key, {"comment": "..."}) ### Reading Metadata ```python -meta = metadata.get_metadata(item_key) -comment = meta.get("comment", "") -``` - -### Writing Metadata -```python -metadata.update_metadata(item_key, { - "comment": "Updated description", - "modified": datetime.now().isoformat(), - "author": "user@example.com" -}) +meta = metadata.get_metadata(activity_key, fields=["name", "comment"]) +meta = metadata.get_database_metadata(database_name, fields=["description"]) ``` ### Searching Metadata ```python results = metadata.search(query="renewable energy") ``` - -## Signal Integration - -Metadata changes emit signals: - -```python -from activity_browser import app - -app.signals.metadata_changed.emit(item_key) -``` - -Other components can listen and update their displays accordingly. - -## Development Guidelines - -When working with metadata: - -1. **Use the MetaDataStore** - Don't create separate storage -2. **Emit signals** - Notify when metadata changes -3. **Validate schemas** - Ensure metadata structure is consistent -4. **Handle missing data** - Provide sensible defaults -5. **Consider performance** - Cache frequently accessed metadata -6. **Backup regularly** - Metadata is user-created content -7. **Version metadata format** - Support migration if schema changes - -## Data Structure - -Typical metadata structure: - -```json -{ - "comment": "User-provided description", - "tags": ["renewable", "electricity"], - "data_quality": { - "reliability": 3, - "completeness": 4, - "temporal_correlation": 2 - }, - "modified": "2025-12-10T10:30:00", - "author": "user@example.com", - "custom_fields": { - "project_code": "ABC123" - } -} -``` - -## Integration with UI - -Metadata is displayed and edited through: -- Activity details page -- Database properties dialog -- Method information panel -- Custom metadata editor dialogs - -Users can add, edit, and delete metadata through these interfaces. diff --git a/activity_browser/bwutils/searchengine/README.md b/activity_browser/bwutils/searchengine/README.md deleted file mode 100644 index b2c8accf5..000000000 --- a/activity_browser/bwutils/searchengine/README.md +++ /dev/null @@ -1,179 +0,0 @@ -# searchengine - -Search functionality for activities, exchanges, and other LCA data. - -## Overview - -This directory implements the search engine that enables users to quickly find activities, databases, methods, and other items across their LCA data. - -## Features - -### Full-Text Search -- Search across activity names -- Search in comments and descriptions -- Search in product/flow names -- Search in metadata fields - -### Filtered Search -- Filter by database -- Filter by location -- Filter by unit -- Filter by activity type - -### Advanced Search -- Boolean operators (AND, OR, NOT) -- Wildcard matching -- Regular expressions -- Field-specific queries - -### Fast Indexing -- Incremental index updates -- Background indexing -- Efficient data structures -- Cached results - -## Architecture - -The search engine consists of: - -1. **Indexer** - Builds searchable index from Brightway2 data -2. **Query Parser** - Parses user search queries -3. **Search Engine** - Performs actual search operations -4. **Result Ranker** - Orders results by relevance -5. **Cache** - Stores recent search results - -## Usage Pattern - -```python -from activity_browser.bwutils.searchengine import SearchEngine - -engine = SearchEngine() - -# Simple search -results = engine.search("electricity") - -# Filtered search -results = engine.search( - query="electricity", - database="ecoinvent", - location="CH" -) - -# Advanced search -results = engine.search("(wind OR solar) AND electricity") -``` - -## Index Management - -The search index is automatically maintained: -- Built on first use -- Updated when databases change -- Rebuilt when necessary -- Stored in user data directory - -### Triggering Updates -```python -from activity_browser import app - -# Index automatically updates on these signals: -app.signals.database_changed.emit() -app.signals.activity_modified.emit() -``` - -## Search Results - -Results include: -- Activity key (database, code) -- Activity name -- Product name -- Location -- Unit -- Relevance score -- Highlighted matches - -```python -for result in results: - print(f"{result['name']} ({result['location']})") - print(f"Score: {result['score']}") -``` - -## Performance Considerations - -### Optimization Strategies -- Index only relevant fields -- Use appropriate data structures (tries, inverted indexes) -- Cache frequent queries -- Limit result set size -- Lazy loading of full activity data - -### Threading -Search operations run in background threads: -```python -from activity_browser.ui.core.threading import ABThread - -worker = ABThread(engine.search, query) -worker.finished.connect(display_results) -worker.start() -``` - -## Search Syntax - -### Basic Search -``` -electricity -``` - -### Phrase Search -``` -"wind power" -``` - -### Boolean Operators -``` -wind AND electricity -solar OR wind -electricity NOT coal -``` - -### Field-Specific -``` -name:electricity location:CH unit:kWh -``` - -### Wildcards -``` -electr* # Prefix matching -*city # Suffix matching -el*city # Both -``` - -## Integration with UI - -Search is accessible via: -- Global search bar in toolbar -- Database browser filter -- Activity browser search -- Quick search dialogs -- Context menu search - -## Development Guidelines - -When working with search: - -1. **Index incrementally** - Update index, don't rebuild -2. **Run in background** - Don't block UI -3. **Limit results** - Provide pagination for large result sets -4. **Highlight matches** - Show why result matched -5. **Sort by relevance** - Put best matches first -6. **Support fuzzy matching** - Handle typos gracefully -7. **Cache wisely** - Balance memory vs. speed -8. **Profile performance** - Ensure searches complete quickly - -## Testing - -Test search with: -- Small and large databases -- Various query types -- Edge cases (special characters, unicode) -- Performance benchmarks -- Index rebuild scenarios diff --git a/activity_browser/bwutils/superstructure/README.md b/activity_browser/bwutils/superstructure/README.md deleted file mode 100644 index efb2ddae2..000000000 --- a/activity_browser/bwutils/superstructure/README.md +++ /dev/null @@ -1,179 +0,0 @@ -# superstructure - -Superstructure scenario analysis tools for Activity Browser. - -## Overview - -This directory implements superstructure functionality, which enables scenario-based LCA analysis. Superstructures allow users to model multiple scenarios within a single database by using parameters to switch between alternative technologies, processes, or supply chains. - -## What is a Superstructure? - -A superstructure is an LCA model that contains multiple possible configurations: -- Alternative technologies (e.g., different energy sources) -- Multiple scenarios (e.g., current vs. future) -- Prospective databases (e.g., from [premise](https://premise.readthedocs.io/)) -- Switchable pathways (e.g., different material choices) - -Parameters control which alternatives are "active" in each scenario. - -## Key Concepts - -### Scenarios -Named configurations that define parameter values: -```python -scenarios = { - "baseline": {"electricity_grid_mix": 0.7, "renewable_share": 0.3}, - "high_renewable": {"electricity_grid_mix": 0.2, "renewable_share": 0.8} -} -``` - -### Parameters -Variables that control exchange amounts or activity selection: -- **Amount parameters** - Control exchange quantities -- **Switch parameters** - Enable/disable exchanges (0 or 1) -- **Share parameters** - Allocate between alternatives (sum to 1) - -### Alternative Processes -Multiple activities representing different technology choices: -- Linked via parameterized exchanges -- Only one "active" per scenario -- Controlled by parameter values - -## Features - -### Scenario Management -- Create, edit, delete scenarios -- Copy scenarios for variations -- Compare scenarios side-by-side -- Switch between scenarios - -### Parameter Configuration -- Define parameter ranges -- Set scenario-specific values -- Link parameters to exchanges -- Validate parameter consistency - -### Scenario Calculations -- Run LCA for multiple scenarios -- Compare results across scenarios -- Visualize scenario differences -- Export scenario results - -## Usage Pattern - -### Creating a Superstructure -```python -from activity_browser.bwutils.superstructure import Superstructure - -# Create superstructure -ss = Superstructure(name="Energy scenarios") - -# Add scenarios -ss.add_scenario("baseline", parameters={...}) -ss.add_scenario("high_renewable", parameters={...}) -``` - -### Running Scenario Analysis -```python -# Calculate all scenarios -results = ss.calculate_scenarios() - -# Compare results -comparison = ss.compare_scenarios(["baseline", "high_renewable"]) -``` - -## Integration with Parameters - -Superstructures leverage Activity Browser's parameter system: -- Project parameters define scenarios -- Database parameters set alternative values -- Activity parameters control exchanges -- Formulas link parameters together - -See `app/pages/parameters/` for parameter management UI. - -## Integration with Premise - -Activity Browser supports prospective databases from [premise](https://premise.readthedocs.io/): -- Import premise scenarios -- Map to Activity Browser scenarios -- Run temporal LCA analyses -- Visualize future pathways - -## Visualization - -Superstructure results can be visualized as: -- **Bar charts** - Compare impacts across scenarios -- **Radar charts** - Multi-dimensional scenario comparison -- **Heatmaps** - Parameter sensitivity across scenarios -- **Sankey diagrams** - Flow differences between scenarios - -## File Format - -Superstructures can be saved/loaded: -```json -{ - "name": "Energy scenarios", - "scenarios": { - "baseline": { - "parameters": {...}, - "description": "Current situation" - }, - "future": { - "parameters": {...}, - "description": "2050 scenario" - } - }, - "reference_flow": {...}, - "methods": [...] -} -``` - -## Development Guidelines - -When working with superstructures: - -1. **Validate parameters** - Ensure consistency across scenarios -2. **Check constraints** - Share parameters should sum to 1 -3. **Handle errors** - Gracefully handle missing or invalid parameters -4. **Use threading** - Scenario calculations can be slow -5. **Cache results** - Avoid recalculating unchanged scenarios -6. **Emit signals** - Notify when scenarios change -7. **Support undo** - Allow reverting parameter changes - -## Advanced Features - -### Sensitivity Analysis -Test parameter importance: -```python -sensitivity = ss.sensitivity_analysis( - parameter="renewable_share", - range=(0, 1), - steps=10 -) -``` - -### Optimization -Find best parameter values: -```python -optimal = ss.optimize( - objective="minimize_impact", - constraints={...} -) -``` - -### Monte Carlo with Scenarios -Combine uncertainty and scenarios: -```python -results = ss.monte_carlo( - scenario="future", - iterations=1000 -) -``` - -## Related Modules - -- `app/pages/parameters/` - Parameter management UI -- `bwutils/multilca.py` - Multi-functional LCA calculations -- `bwutils/sensitivity_analysis.py` - Sensitivity analysis tools -- `bwutils/montecarlo.py` - Monte Carlo simulation diff --git a/activity_browser/mod/bw2analyzer/README.md b/activity_browser/mod/bw2analyzer/README.md deleted file mode 100644 index 2c21210fb..000000000 --- a/activity_browser/mod/bw2analyzer/README.md +++ /dev/null @@ -1,185 +0,0 @@ -# bw2analyzer - -Monkey-patches for brightway2-analyzer library. - -## Overview - -This directory contains modifications and patches to the brightway2-analyzer library. These patches fix bugs, add features, or adapt functionality specifically for Activity Browser's needs. - -## Key Files - -- **`contribution.py`** - Patches for contribution analysis functions -- **`__init__.py`** - Module initialization and patch application - -## Purpose - -Brightway2-analyzer provides LCA analysis tools including: -- Contribution analysis -- Graph traversal -- Tagged exchanges -- Monte Carlo analysis helpers - -Activity Browser patches this library to: -- Fix issues not yet addressed upstream -- Add GUI-specific functionality -- Improve performance for interactive use -- Handle edge cases - -## Contribution Analysis Patches - -The `contribution.py` file likely patches contribution analysis to: -- Handle large result sets more efficiently -- Provide progress callbacks for GUI -- Fix calculation edge cases -- Add sorting and filtering options -- Improve memory usage - -## Common Patches - -### Progress Callbacks -Add callbacks for long-running operations: -```python -def contribution_analysis(lca, progress_callback=None): - # Original function doesn't support callbacks - # Patch adds progress updates for GUI - for i, item in enumerate(items): - if progress_callback: - progress_callback(i / len(items) * 100) - # ... process item -``` - -### Error Handling -Improve error messages for GUI context: -```python -def patched_function(*args, **kwargs): - try: - return original_function(*args, **kwargs) - except Exception as e: - # Convert to user-friendly error - raise ABError(f"Analysis failed: {str(e)}") -``` - -### Performance Optimizations -Speed up operations for interactive use: -```python -def optimized_function(data): - # Add caching for repeated calls - cache_key = hash_data(data) - if cache_key in cache: - return cache[cache_key] - result = expensive_operation(data) - cache[cache_key] = result - return result -``` - -## Patch Application - -Patches are applied when the module is imported: - -```python -# In activity_browser/mod/__init__.py -import activity_browser.mod.bw2analyzer as bw2analyzer -``` - -This replaces the original bw2analyzer with the patched version. - -## Development Guidelines - -When adding patches: - -1. **Minimal changes** - Only patch what's necessary -2. **Document reasons** - Explain why each patch is needed -3. **Track upstream** - Monitor if fix is applied upstream -4. **Version awareness** - Handle different bw2analyzer versions -5. **Test thoroughly** - Ensure patches don't break existing functionality -6. **Consider alternatives** - Can it be done in AB code instead? - -## Contribution Analysis - -Typical contribution analysis patches might include: - -### Cutoff Support -```python -def contribution_analysis(lca, cutoff=0.01): - """Add cutoff parameter to limit results.""" - # Original doesn't support cutoff - # Patch filters results below threshold -``` - -### Sorting Options -```python -def contribution_analysis(lca, sort_by='amount'): - """Add sorting parameter.""" - # Original returns unsorted - # Patch adds sorting by amount, name, or impact -``` - -### Result Formatting -```python -def contribution_analysis(lca, format='dict'): - """Control output format.""" - # Original returns specific format - # Patch allows choosing format (dict, list, DataFrame) -``` - -## Testing Patches - -Test patches with: -- Unit tests for patched functions -- Integration tests with real LCA data -- Comparison with original behavior -- Edge cases and error conditions -- Performance benchmarks - -## Maintenance - -When updating Activity Browser: - -1. **Check brightway2-analyzer version** - New version may fix issues -2. **Review patches** - Are they still needed? -3. **Test compatibility** - Ensure patches work with new version -4. **Update if needed** - Adjust patches for API changes -5. **Contribute upstream** - Submit fixes to brightway2-analyzer - -## Alternative to Patching - -Instead of patching, consider: -- Wrapping functions in AB code -- Using composition instead of modification -- Contributing fixes directly to brightway2 -- Using configuration/options if available - -Patching should be last resort when: -- Upstream fix is not available -- Functionality is GUI-specific -- Performance optimization is needed -- Workaround is required - -## Risks of Patching - -Be aware that patches: -- May break with upstream updates -- Can cause confusion (behavior differs from docs) -- Require maintenance -- May conflict with other patches -- Complicate debugging - -## Documentation - -Always document: -- What is patched -- Why it's patched -- When it can be removed -- Any side effects -- Upstream issue tracking - -## Contributing Upstream - -When possible, contribute patches upstream: -1. Open issue on brightway2-analyzer -2. Propose fix or enhancement -3. Submit pull request -4. Maintain patch until merged -5. Remove patch once in released version - -This benefits the entire Brightway community and reduces AB maintenance burden. From 19d6b5113d1b47dd05da2805053adfcf5051c37b Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 11 Dec 2025 10:06:33 +0100 Subject: [PATCH 234/267] Deferred syncing --- .../parameterized_exchanges_section.py | 29 +++++++++++++++---- .../pages/parameters/parameters_section.py | 26 ++++++++++++++--- activity_browser/app/panes/databases.py | 27 +++++++++++++---- 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py index ef9ee22be..13c76dcaf 100644 --- a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py +++ b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py @@ -31,6 +31,7 @@ def __init__(self, parent=None): parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. """ super().__init__(parent) + self._populate_later_flag = False # Parameterized exchanges table view self.model = ParameterizedExchangesModel(parent=self) @@ -53,12 +54,28 @@ def connect_signals(self): """ Connects signals to their respective slots. """ - app.signals.metadata.synced.connect(self.sync) - app.signals.parameter.changed.connect(self.sync) - app.signals.parameter.recalculated.connect(self.sync) - app.signals.parameter.deleted.connect(self.sync) - # app.signals.project.changed.connect(self.sync) - # app.signals.meta.databases_changed.connect(self.sync) + app.signals.metadata.synced.connect(self.syncLater) + app.signals.parameter.changed.connect(self.syncLater) + app.signals.parameter.recalculated.connect(self.syncLater) + app.signals.parameter.deleted.connect(self.syncLater) + app.signals.project.changed.connect(self.syncLater) + app.signals.meta.databases_changed.connect(self.syncLater) + + def syncLater(self): + """ + Schedules a sync operation to be performed later. + """ + + def slot(): + self._populate_later_flag = False + self.sync() + self.thread().eventDispatcher().awake.disconnect(slot) + + if self._populate_later_flag: + return + + self._populate_later_flag = True + self.thread().eventDispatcher().awake.connect(slot) def sync(self): """ diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py index 8eff88c50..df4513103 100644 --- a/activity_browser/app/pages/parameters/parameters_section.py +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -35,6 +35,7 @@ def __init__(self, parent=None): parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. """ super().__init__(parent) + self._populate_later_flag = False # Parameters tree view self.model = ProjectParametersModel(parent=self) @@ -58,11 +59,28 @@ def connect_signals(self): """ Connects signals to their respective slots. """ - app.signals.metadata.synced.connect(self.sync) + app.signals.metadata.synced.connect(self.syncLater) + app.signals.project.changed.connect(self.syncLater) - app.signals.parameter.changed.connect(self.sync) - app.signals.parameter.recalculated.connect(self.sync) - app.signals.parameter.deleted.connect(self.sync) + app.signals.parameter.changed.connect(self.syncLater) + app.signals.parameter.recalculated.connect(self.syncLater) + app.signals.parameter.deleted.connect(self.syncLater) + + def syncLater(self): + """ + Schedules a sync operation to be performed later. + """ + + def slot(): + self._populate_later_flag = False + self.sync() + self.thread().eventDispatcher().awake.disconnect(slot) + + if self._populate_later_flag: + return + + self._populate_later_flag = True + self.thread().eventDispatcher().awake.connect(slot) def sync(self): """ diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py index 20a379450..92b4d1ece 100644 --- a/activity_browser/app/panes/databases.py +++ b/activity_browser/app/panes/databases.py @@ -32,6 +32,8 @@ def __init__(self, parent): parent (QtWidgets.QWidget): The parent widget. """ super().__init__(parent) + self._populate_later_flag = False + self.model = DatabasesModel(parent=self) self.view = DatabasesView() self.view.setModel(self.model) @@ -46,10 +48,10 @@ def connect_signals(self): """ Connects the signals to the appropriate slots. """ - app.signals.meta.databases_changed.connect(self.sync) - app.signals.metadata.synced.connect(self.sync) - app.signals.database.deleted.connect(self.sync) - app.signals.database_read_only_changed.connect(self.sync) + app.signals.meta.databases_changed.connect(self.syncLater) + app.signals.metadata.synced.connect(self.syncLater) + app.signals.database.deleted.connect(self.syncLater) + app.signals.database_read_only_changed.connect(self.syncLater) def build_layout(self): """ @@ -60,7 +62,22 @@ def build_layout(self): layout.setContentsMargins(5, 0, 5, 5) self.setLayout(layout) - @QtCore.Slot() + def syncLater(self): + """ + Schedules a sync operation to be performed later. + """ + + def slot(): + self._populate_later_flag = False + self.sync() + self.thread().eventDispatcher().awake.disconnect(slot) + + if self._populate_later_flag: + return + + self._populate_later_flag = True + self.thread().eventDispatcher().awake.connect(slot) + def sync(self): """ Synchronizes the model with the current state of the databases. From baf9d61fc368a2a90b606bb9244ffec35897f378 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 11 Dec 2025 10:06:43 +0100 Subject: [PATCH 235/267] Readme update --- recipe/README.md | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/recipe/README.md b/recipe/README.md index 3a2bb6547..bb76dc36b 100644 --- a/recipe/README.md +++ b/recipe/README.md @@ -58,16 +58,6 @@ requirements: # ... more dependencies ``` -### Test Section -Basic tests to verify package installation: -```yaml -test: - imports: - - activity_browser - commands: - - activity-browser --help -``` - ### About Section Package metadata: ```yaml @@ -140,21 +130,6 @@ Or with mamba (faster): mamba install -c conda-forge activity-browser ``` -## Testing the Package - -After building, test the package: - -```bash -# Install from local build -conda install --use-local activity-browser - -# Test entry point -activity-browser --version - -# Launch application -activity-browser -``` - ## Dependencies Keep dependencies in sync: From 53ad7432fcb561509f7e6efe6320d5824dc473ff Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 11 Dec 2025 10:25:37 +0100 Subject: [PATCH 236/267] Signal logging --- activity_browser/__init__.py | 2 +- activity_browser/app/signalling.py | 47 ++++++++++++++++-------------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/activity_browser/__init__.py b/activity_browser/__init__.py index bc22de453..e04ee0533 100644 --- a/activity_browser/__init__.py +++ b/activity_browser/__init__.py @@ -21,9 +21,9 @@ def setup_logging(): import platformdirs logger.level("SYNC", no=9, color="") + logger.level("SIGNAL", no=19, color="") logger.level("TEST", no=19, color="") - logger.remove() logger.add(sys.stderr, level=6, colorize=True, format="{time:HH:mm:ss} | {level: <8} | {message}") diff --git a/activity_browser/app/signalling.py b/activity_browser/app/signalling.py index 9ead8c0ac..16fd0d744 100644 --- a/activity_browser/app/signalling.py +++ b/activity_browser/app/signalling.py @@ -72,7 +72,7 @@ def _flush_metadata(self): t = time() self.synced.emit(added, updated, deleted) - logger.debug(f"Metadatastore sync signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Metadata: synced: {time() - t:.2f} seconds") class SettingSignals(QObject): changed = Signal() # Settings have changed @@ -85,8 +85,9 @@ def __init__(self, parent=None): def emit_changed(self, *args, **kwargs): """Emit the changed signal.""" + t = time() self.changed.emit() - logger.debug("Settings changed signal emitted") + logger.log("SIGNAL", f"Settings: changed: {time() - t:.2f} seconds") class ABSignals(QObject): @@ -186,15 +187,15 @@ def _on_signaleddataset_on_save(self, sender, old, new): if isinstance(new, ActivityDataset): t = time() self.node.changed.emit(new, old) - logger.debug(f"Activity changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Node: changed: {time() - t:.2f} seconds") elif isinstance(new, ExchangeDataset): t = time() self.edge.changed.emit(new, old) - logger.debug(f"Exchange changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Edge: changed: {time() - t:.2f} seconds") elif isinstance(new, (ProjectParameter, DatabaseParameter, ActivityParameter)): t = time() self.parameter.changed.emit(new, old) - logger.debug(f"Parameter changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Parameter: changed: {time() - t:.2f} seconds") else: logger.debug(f"Unknown dataset type changed: {type(new)}") @@ -205,90 +206,90 @@ def _on_signaleddataset_on_delete(self, sender, old): if isinstance(old, ActivityDataset): t = time() self.node.deleted.emit(old) - logger.debug(f"Activity deleted signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Node: deleted: {time() - t:.2f} seconds") elif isinstance(old, ExchangeDataset): t = time() self.edge.deleted.emit(old) - logger.debug(f"Exchange deleted signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Edge: deleted: {time() - t:.2f} seconds") elif isinstance(old, (ProjectParameter, DatabaseParameter, ActivityParameter)): t = time() self.parameter.deleted.emit(old) - logger.debug(f"Parameter deleted signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Parameter: deleted: {time() - t:.2f} seconds") else: logger.debug(f"Unknown dataset type deleted: {type(old)}") def _on_activity_database_change(self, sender, old, new): t = time() self.node.database_change.emit(old, new) - logger.debug(f"Activity db changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Node: database_change: {time() - t:.2f} seconds") def _on_activity_code_change(self, sender, old, new): t = time() self.node.code_change.emit(old, new) - logger.debug(f"Activity code changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Node: code_change: {time() - t:.2f} seconds") def _on_database_delete(self, sender, name): t = time() self.database.deleted.emit(name) - logger.debug(f"Database deleted signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Database: deleted: {time() - t:.2f} seconds") def _on_database_reset(self, sender, name): from bw2data import Database t = time() self.database.reset.emit(Database(name)) - logger.debug(f"Database reset signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Database: reset: {time() - t:.2f} seconds") def _on_database_write(self, sender, name): from bw2data import Database t = time() self.database.written.emit(Database(name)) - logger.debug(f"Database write signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Database: written: {time() - t:.2f} seconds") def _on_project_changed(self, ds): t = time() self.project.changed.emit(ds, self._project_dataset) self._project_dataset = ds - logger.debug(f"Project changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Project: changed: {time() - t:.2f} seconds") def _on_project_created(self, ds): t = time() self.project.created.emit() - logger.debug(f"Project created signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Project: created: {time() - t:.2f} seconds") def _on_database_metadata_change(self, sender, old, new): t = time() self.meta.databases_changed.emit(old, new) - logger.debug(f"DB metadata changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Meta: databased_changed: {time() - t:.2f} seconds") def _on_methods_metadata_change(self, sender, old, new): t = time() self.meta.methods_changed.emit(old, new) - logger.debug(f"Methods metadata changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Meta: methods_changed: {time() - t:.2f} seconds") def _on_cs_metadata_change(self, sender, old, new): t = time() self.meta.calculation_setups_changed.emit(old, new) - logger.debug(f"CS metadata changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Meta: calculation_setups_changed: {time() - t:.2f} seconds") def _on_method_write(self, sender): t = time() self.method.changed.emit(sender) - logger.debug(f"Method changed signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Method: changed: {time() - t:.2f} seconds") def _on_method_deregister(self, sender): t = time() self.method.deleted.emit(sender) - logger.debug(f"Method deleted signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Method: deleted: {time() - t:.2f} seconds") def _on_parameter_recalculate(self, sender, *args, **kwargs): t = time() self.parameter.recalculated.emit() - logger.debug(f"Param recalculated signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Parameter: recalculated: {time() - t:.2f} seconds") def _on_parameterized_exchange_recalculate(self, sender, *args, **kwargs): t = time() self.edge.recalculated.emit() - logger.debug(f"Param exchange recalculated signal completed in {time() - t:.2f} seconds") + logger.log("SIGNAL", f"Edge: recalculated: {time() - t:.2f} seconds") def patch_methods_datastore(): @@ -318,7 +319,9 @@ def patch_projects(): def delete_project(self, name=None, delete_dir=False): from activity_browser.app import signals original_delete(self, name, delete_dir) + t = time() signals.project.deleted.emit(name) + logger.log("SIGNAL", f"Project: deleted: {time() - t:.2f} seconds") original_delete = ProjectManager.delete_project From b16ed483eb65fc7c74d551291b7a188960b6bf52 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 11 Dec 2025 12:03:38 +0100 Subject: [PATCH 237/267] Fix project param bug on empty project --- activity_browser/app/pages/parameters/parameters_section.py | 2 ++ activity_browser/ui/core/tree_model.py | 1 + 2 files changed, 3 insertions(+) diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py index df4513103..e162e55e9 100644 --- a/activity_browser/app/pages/parameters/parameters_section.py +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -61,6 +61,8 @@ def connect_signals(self): """ app.signals.metadata.synced.connect(self.syncLater) app.signals.project.changed.connect(self.syncLater) + app.signals.meta.databases_changed.connect(self.syncLater) + app.signals.database.deleted.connect(self.syncLater) app.signals.parameter.changed.connect(self.syncLater) app.signals.parameter.recalculated.connect(self.syncLater) diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py index 9f783575f..86a020446 100644 --- a/activity_browser/ui/core/tree_model.py +++ b/activity_browser/ui/core/tree_model.py @@ -430,6 +430,7 @@ def build_df_index(self): # Remove the original column from the dataframe df = df.drop(columns=[col]) + df = df.dropna(how='all', axis=1) df["index"] = range(len(df)) new_index = pd.MultiIndex.from_frame(df) From 1ec962444380cca9ccf58cab8039a47d6e8f4c69 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 11 Dec 2025 12:21:02 +0100 Subject: [PATCH 238/267] Parameter menu fixes --- .../actions/parameter/parameter_group_delete.py | 14 +++++++++----- .../parameters/parameterized_exchanges_section.py | 2 +- .../app/pages/parameters/parameters_section.py | 11 ++++++++--- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/activity_browser/app/actions/parameter/parameter_group_delete.py b/activity_browser/app/actions/parameter/parameter_group_delete.py index 6902d1517..b438b747a 100644 --- a/activity_browser/app/actions/parameter/parameter_group_delete.py +++ b/activity_browser/app/actions/parameter/parameter_group_delete.py @@ -1,13 +1,13 @@ -from typing import Any +from loguru import logger -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from bw2data import get_activity +import bw2data as bd from bw2data.parameters import (ActivityParameter, Group, GroupDependency, parameters) + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons -from activity_browser.bwutils.utils import Parameter class ParameterGroupDelete(ABAction): @@ -22,6 +22,10 @@ class ParameterGroupDelete(ABAction): @exception_dialogs def run(parameter_groups: list[str]): for group in parameter_groups: + if group in ["project"] + list(bd.databases): + logger.warning(f"Cannot delete built-in parameter group '{group}'. Skipping.") + continue + group_entry = Group.get(Group.name == group) # Delete all parameters in the group diff --git a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py index 13c76dcaf..be4a2663e 100644 --- a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py +++ b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py @@ -149,7 +149,7 @@ class ParameterizedExchangesView(widgets.ABTreeView): "uncertainty": delegates.UncertaintyDelegate, } - class ContextMenu(widgets.ABMenu): + class ContextMenu(QtWidgets.QMenu): """ A context menu for the ParameterizedExchangesView. """ diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py index e162e55e9..56d938202 100644 --- a/activity_browser/app/pages/parameters/parameters_section.py +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -246,7 +246,7 @@ class ContextMenu(widgets.ABMenu): text="Delete parameter(s)", enable=(all([p.deletable for p in p.selected_parameters()]) and len(p.selected_parameters()) > 0) - and all([not database_is_locked(p.database) + and all([not database_is_locked(p.data['database']) for p in p.selected_parameters() if p.param_type != "project" ]) @@ -254,7 +254,9 @@ class ContextMenu(widgets.ABMenu): lambda m, p: m.add(app.actions.ParameterGroupDelete, p.selected_groups(), text="Delete parameter group(s)", enable=(len(p.selected_groups()) > 0 - and all([not database_is_locked(p.database) + and all([g not in ["project"] + list(bd.databases) + for g in p.selected_groups()]) + and all([not database_is_locked(p.data['database']) for p in p.selected_parameters() if p.param_type != "project" ]) @@ -361,7 +363,10 @@ def decorationData(self, index: QtCore.QModelIndex) -> any: column_name = self.column_name(index) if column_name == "amount": - return icons.qicons.empty if pd.isna(self.get(index, "formula")) else icons.qicons.parameterized + formula = self.get(index, "formula") + formula = isinstance(formula, str) and formula.strip() + + return icons.qicons.parameterized if formula else icons.qicons.empty return None From 2159e24c7aef27f57a3e7f43f8bb8f307dac050e Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 11 Dec 2025 13:54:35 +0100 Subject: [PATCH 239/267] Fix activity parameter section --- .../pages/activity_details/parameters_tab.py | 229 ++++++++++++++---- .../pages/parameters/parameters_section.py | 12 +- 2 files changed, 182 insertions(+), 59 deletions(-) diff --git a/activity_browser/app/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py index c8fbe6395..800f7999b 100644 --- a/activity_browser/app/pages/activity_details/parameters_tab.py +++ b/activity_browser/app/pages/activity_details/parameters_tab.py @@ -1,13 +1,16 @@ -from qtpy import QtWidgets, QtCore +from qtpy import QtWidgets, QtCore, QtGui from qtpy.QtCore import Qt from loguru import logger import pandas as pd import bw2data as bd +from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, Group, ParameterBase + from activity_browser import app from activity_browser.ui import widgets, icons, delegates, core from activity_browser.bwutils.commontasks import refresh_node, refresh_parameter, parameters_in_scope, database_is_locked, node_group +from activity_browser.bwutils.utils import Parameter class ParametersTab(QtWidgets.QWidget): @@ -61,45 +64,123 @@ def sync(self): """ logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - self.activity = refresh_node(self.activity) df = self.build_df() - df.reset_index(drop=True, inplace=True) - self.model.set_dataframe(df) - self.model.group(["_scope"]) + self.model.set_dataframe(df, group=["_param_type", "_scope"]) self.view.expandAll() + self.view.resizeColumnToContents(1) + self.view.resizeColumnToContents(3) + self.view.resizeColumnToContents(4) + def build_df(self) -> pd.DataFrame: """ - Builds a DataFrame from the parameters in scope of the activity. + Builds a DataFrame from all parameters in the project. Returns: pd.DataFrame: The DataFrame containing the parameters data. """ - data = parameters_in_scope(self.activity) - translated = [] - for name, param in data.items(): - row = param._asdict() - row["uncertainty"] = param.uncertainty - row["formula"] = param.data.get("formula") - row["comment"] = param.data.get("comment") - row["_parameter"] = param - row["_activity"] = self.activity - - if param.param_type == "project": - row["_scope"] = "Current project" - elif param.param_type == "database": - row["_scope"] = "This database" - elif param.group == node_group(self.activity): - row["_scope"] = "This activity" - else: - row["_scope"] = f"Group: {param.group}" + # Project parameters + for param in ProjectParameter.select(): + row = self._parameter_to_row(param) + translated.append(row) + + translated.append({ + "name": "New parameter...", + "_group": "project", + "_param_type": "project", + "_class": "new", + }) + + # Database parameters + db_params = DatabaseParameter.select() + db_name = self.activity["database"] + + for param in db_params.where(DatabaseParameter.database == db_name): + row = self._parameter_to_row(param, db_name, db_name) + translated.append(row) + if not database_is_locked(db_name): + translated.append({ + "name": "New parameter...", + "_scope": db_name, + "_database": db_name, + "_group": db_name, + "_param_type": "database", + "_class": "new", + }) + + # Activity parameters + act_params = ActivityParameter.select() + group_name = node_group(self.activity) or str(self.activity.id) + + for param in act_params.where(ActivityParameter.group == group_name): + row = self._parameter_to_row(param, f"Group: {group_name}", param.database) translated.append(row) - columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_activity"] - return pd.DataFrame(translated, columns=columns) + if not database_is_locked(self.activity["database"]): + translated.append({ + "name": "New parameter...", + "_scope": f"Group: {group_name}", + "_database": self.activity["database"], + "_group": group_name, + "_param_type": "activity", + "_class": "new", + }) + + columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_database", "_group", + "_param_type", "_class"] + df = pd.DataFrame(translated, columns=columns) + + df["_activity"] = [self.activity for i in range(len(df))] + return df + + def _parameter_to_row(self, param, scope_label: str = None, database: str = None) -> dict: + """ + Converts a parameter to a row dictionary. + + Args: + param: The parameter to convert (ProjectParameter, DatabaseParameter, or ActivityParameter). + scope_label: The label for the scope (e.g., "Current project", "Database: ecoinvent"). + database: The database name (None for project parameters). + + Returns: + dict: A dictionary representing the parameter row. + """ + data = param.dict + + # Create Parameter wrapper + if isinstance(param, ProjectParameter): + parameter = Parameter(param.name, "project", data.get("amount"), data, "project") + group = "project" + param_type = "project" + elif isinstance(param, DatabaseParameter): + parameter = Parameter(param.name, param.database, data.get("amount"), data, "database") + group = param.database + param_type = "database" + elif isinstance(param, ActivityParameter): + parameter = Parameter(param.name, param.group, data.get("amount"), data, "activity") + group = param.group + param_type = "activity" + else: + raise ValueError(f"Unknown parameter type: {type(param)}") + + row = { + "name": parameter.name, + "amount": parameter.amount, + "uncertainty": parameter.uncertainty, + "formula": data.get("formula"), + "comment": data.get("comment"), + "_param_type": param_type, + "_parameter": parameter, + "_scope": scope_label, + "_database": database, + "_group": group, + "_class": "instantiated", + } + + return row class ParametersView(widgets.ABTreeView): @@ -192,6 +273,21 @@ def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole. if row is None: return False + # Handle "New parameter..." rows + if row.get("_class") == "new": + if column_name != "name" or value == "": + return False + + parameter = Parameter( + name=value, + group=row.get("_group"), + param_type=row.get("_param_type") + ) + + app.actions.ParameterNewFromParameter.run(parameter) + return True + + # Handle regular parameter edits parameter = row.get("_parameter") if parameter is None: return False @@ -199,7 +295,6 @@ def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole. if column_name in ["amount", "formula", "name", "comment"]: parameter = refresh_parameter(parameter) app.actions.ParameterModify.run(parameter, column_name, value) - return True if column_name == "uncertainty": parameter = refresh_parameter(parameter) @@ -208,7 +303,7 @@ def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole. return True return False - + def decorationData(self, index: QtCore.QModelIndex) -> any: """ Provides decoration data for the model. @@ -220,46 +315,80 @@ def decorationData(self, index: QtCore.QModelIndex) -> any: The decoration data for the index. """ column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return None - - if not isinstance(row, pd.Series): - return None if column_name == "amount": - if pd.isna(row.get("formula")) or row.get("formula") is None or row.get("formula") == "": - return icons.qicons.empty # empty icon to align the values - return icons.qicons.parameterized + formula = self.get(index, "formula") + formula = isinstance(formula, str) and formula.strip() + + return icons.qicons.parameterized if formula else icons.qicons.empty + + return None + + def fontData(self, index: QtCore.QModelIndex) -> any: + """ + Provides font data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide font data. + + Returns: + QtGui.QFont: The font data for the index. + """ + param_class = self.get(index, "_class") + if param_class == "new": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.ExtraLight) + return font + + if param_class == "broken": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.Bold) + return font return None - def indexEditable(self, index): + def indexEditable(self, index: QtCore.QModelIndex) -> bool: + """ + Returns whether the index is editable. + + Args: + index (QtCore.QModelIndex): The index to check. + + Returns: + bool: True if the index is editable, False otherwise. + """ column_name = self.column_name(index) - row = self.row(index) - # Prevent editing if the database is locked - if row is None or database_is_locked(row.get("_activity", {}).get("database")): + # Check if database is locked + database = self.get(index, "_database") + if not pd.isna(database) and database_is_locked(database): + return False + + # Prevent editing broken parameters + if self.get(index, "_class") == "broken": return False # Allow editing for specific columns - if column_name in ["amount", "formula", "uncertainty", "name", "comment"]: + if column_name in ["formula", "uncertainty", "name", "comment"]: + return True + + if column_name == "amount" and not self.get(index, "formula"): return True return False - - def scoped_parameters(self, index): + + def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: """ - Returns the scoped parameters for the index. + Returns the parameters in scope of the parameter at the given index. Args: index (QtCore.QModelIndex): The index to get scoped parameters for. Returns: - dict: A dictionary of scoped parameters for the index. + dict: The parameters in scope. """ - row = self.row(index) - if row is None: + parameter = self.get(index, "_parameter") + if parameter is None or isinstance(parameter, float): # NaN check return {} - return parameters_in_scope(parameter=row.get("_parameter")) + + return parameters_in_scope(parameter=parameter) diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py index 56d938202..272512bb7 100644 --- a/activity_browser/app/pages/parameters/parameters_section.py +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -9,7 +9,7 @@ from activity_browser import app from activity_browser.ui import widgets, icons, delegates, core -from activity_browser.bwutils.commontasks import refresh_parameter, database_is_locked +from activity_browser.bwutils.commontasks import refresh_parameter, database_is_locked, parameters_in_scope from activity_browser.bwutils.utils import Parameter @@ -433,14 +433,8 @@ def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: Returns: dict: The parameters in scope. """ - from activity_browser.bwutils.commontasks import parameters_in_scope - - row = self.row(index) - if row is None: - return {} - - parameter = row.get("_parameter") - if parameter is None: + parameter = self.get(index, "_parameter") + if parameter is None or isinstance(parameter, float): # NaN check return {} return parameters_in_scope(parameter=parameter) From 636245f02ffc0f7a3d3388cbf04c5eb129893804 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 11 Dec 2025 13:59:34 +0100 Subject: [PATCH 240/267] Fix sorting errors --- .../app/pages/activity_details/consumers_tab.py | 2 +- .../app/pages/activity_details/exchanges_tab.py | 2 +- .../app/pages/activity_details/parameters_tab.py | 13 +++---------- .../app/pages/parameters/parameters_section.py | 4 +++- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/activity_browser/app/pages/activity_details/consumers_tab.py b/activity_browser/app/pages/activity_details/consumers_tab.py index 6240fb7e9..02546bded 100644 --- a/activity_browser/app/pages/activity_details/consumers_tab.py +++ b/activity_browser/app/pages/activity_details/consumers_tab.py @@ -32,7 +32,7 @@ def __init__(self, activity: tuple | int | bd.Node, parent=None): self.activity = refresh_node(activity) self.view = ConsumersView(self) - self.model = ConsumersModel(parent=self) + self.model = ConsumersModel(parent=self, enable_sorting=True) self.view.setModel(self.model) self.view.setSortingEnabled(True) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index d6a6a9ec1..30e37a172 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -608,7 +608,7 @@ class ExchangesModel(core.ABTreeModel): A model representing the data for the exchanges. """ def __init__(self, tab: ExchangesTab): - super().__init__(parent=tab) + super().__init__(parent=tab, enable_sorting=True) self.tab = tab def mimeTypes(self) -> list[str]: diff --git a/activity_browser/app/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py index 800f7999b..dd055fbb3 100644 --- a/activity_browser/app/pages/activity_details/parameters_tab.py +++ b/activity_browser/app/pages/activity_details/parameters_tab.py @@ -34,6 +34,9 @@ def __init__(self, activity, parent=None): self.activity = refresh_node(activity) self.view = ParametersView(self) + self.view.setSortingEnabled(False) + self.view.setUniformRowHeights(True) + self.model = ParametersModel(tab=self) self.view.setModel(self.model) @@ -223,16 +226,6 @@ def parameters(self): # Convert to peewee models return [p.to_peewee_model() for p in params if p is not None] - def __init__(self, parent): - """ - Initializes the ParametersView. - - Args: - parent (QtWidgets.QWidget): The parent widget. - """ - super().__init__(parent) - self.setSortingEnabled(True) - @property def activity(self): """ diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py index 272512bb7..aa6f3df5e 100644 --- a/activity_browser/app/pages/parameters/parameters_section.py +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -40,9 +40,11 @@ def __init__(self, parent=None): # Parameters tree view self.model = ProjectParametersModel(parent=self) self.view = ProjectParametersView() - self.view.setModel(self.model) + self.view.setSortingEnabled(False) self.view.setUniformRowHeights(True) + self.view.setModel(self.model) + self.build_layout() self.connect_signals() From 2f5b8ee728ec7a10e8a67c6f797a2e814dd26c65 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 11 Dec 2025 17:01:39 +0100 Subject: [PATCH 241/267] Fixed substitution --- .../pages/activity_details/exchanges_tab.py | 27 +++++++++++++------ .../functional_unit_section.py | 6 ++--- activity_browser/bwutils/commontasks.py | 14 +++++++++- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index 30e37a172..91f67620e 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -11,7 +11,9 @@ import bw_functional as bf from activity_browser import app -from activity_browser.bwutils.commontasks import refresh_node, database_is_locked, database_is_legacy, is_node_product, is_node_biosphere, parameters_in_scope +from activity_browser.bwutils.commontasks import (refresh_node, database_is_locked, database_is_legacy, + is_node_product_or_waste, is_node_biosphere, parameters_in_scope, + is_node_product, is_node_waste) from activity_browser.ui import widgets, icons, delegates, core @@ -292,15 +294,24 @@ def dropEvent(self, event): output = self.output_view.overlay.hovering() keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - exchanges = {"technosphere": set(), "biosphere": set(), "substitution": set()} + + positive_exchanges = {"technosphere": set(), "biosphere": set(), "substitution": set()} + negative_exchanges = {"technosphere": set(), "substitution": set()} for key in keys: - if exc_type := get_exchange_type(key, output=output): - exchanges[exc_type].add(key) + exc_type = get_exchange_type(key, output=output) + if exc_type is None: + continue + if exc_type.startswith("-"): + negative_exchanges[exc_type[1:]].add(key) + else: + positive_exchanges[exc_type].add(key) # Run the action for new exchanges - for exc_type, keys in exchanges.items(): + for exc_type, keys in positive_exchanges.items(): app.actions.ExchangeNew.run(keys, self.activity.key, exc_type) + for exc_type, keys in negative_exchanges.items(): + app.actions.ExchangeNew.run(keys, self.activity.key, exc_type, amount=-1) def action_from_mime(self, mime: core.ABMimeData) -> Literal["product", "waste", "resource", "emission", "generic"]: """ @@ -333,10 +344,10 @@ def action_from_mime(self, mime: core.ABMimeData) -> Literal["product", "waste", return "generic" def get_exchange_type(activity_key: tuple, output=False) -> str | None: - if output and is_node_product(activity_key): - return "substitution" if is_node_product(activity_key): - return "technosphere" + return "substitution" if output else "technosphere" + if is_node_waste(activity_key): + return "-technosphere" if output else "-substitution" elif is_node_biosphere(activity_key): return "biosphere" return None diff --git a/activity_browser/app/pages/calculation_setup/functional_unit_section.py b/activity_browser/app/pages/calculation_setup/functional_unit_section.py index 2a0c6145a..baf8be3cb 100644 --- a/activity_browser/app/pages/calculation_setup/functional_unit_section.py +++ b/activity_browser/app/pages/calculation_setup/functional_unit_section.py @@ -7,7 +7,7 @@ from activity_browser import app from activity_browser.ui import widgets, icons, delegates, core -from activity_browser.bwutils.commontasks import is_node_product +from activity_browser.bwutils.commontasks import is_node_product_or_waste class FunctionalUnitSection(QtWidgets.QWidget): @@ -136,7 +136,7 @@ def dragEnterEvent(self, event): if event.mimeData().hasFormat("application/bw-nodekeylist"): keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") for key in keys: - if not is_node_product(key): + if not is_node_product_or_waste(key): keys.remove(key) if not keys: @@ -150,7 +150,7 @@ def dropEvent(self, event) -> None: keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") for key in keys.copy(): - if not is_node_product(key): + if not is_node_product_or_waste(key): keys.remove(key) app.actions.CSAddFunctionalUnit.run(cs_name, keys) diff --git a/activity_browser/bwutils/commontasks.py b/activity_browser/bwutils/commontasks.py index 1440070f7..9853c1998 100644 --- a/activity_browser/bwutils/commontasks.py +++ b/activity_browser/bwutils/commontasks.py @@ -194,11 +194,14 @@ def get_activity_name(key, str_length=22): return ",".join(key.get("name", "").split(",")[:3])[:str_length] +def is_node_product_or_waste(node: tuple | int | bd.Node) -> bool: + return is_node_product(node) or is_node_waste(node) + def is_node_product(node: tuple | int | bd.Node) -> bool: node = refresh_node(node) raw_type = node._document.type - if raw_type in ["product", "waste", "processwithreferenceproduct"]: + if raw_type in ["product", "processwithreferenceproduct"]: return True if raw_type == "process" and len(node.upstream(kinds=["production"])): @@ -206,6 +209,15 @@ def is_node_product(node: tuple | int | bd.Node) -> bool: return False +def is_node_waste(node: tuple | int | bd.Node) -> bool: + node = refresh_node(node) + raw_type = node._document.type + + if raw_type == "waste": + return True + + return False + def is_node_biosphere(node: tuple | int | bd.Node) -> bool: node = refresh_node(node) From 8dedec2e3857aab336a6175a8c0771159a3691ad Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 11 Dec 2025 17:28:07 +0100 Subject: [PATCH 242/267] MDS settings and living without a searcher --- .../app/pages/settings/__init__.py | 2 + .../app/pages/settings/metadatastore.py | 111 ++++++++++++++++++ .../app/pages/settings/settings_page.py | 5 +- activity_browser/bwutils/metadata/loader.py | 8 +- activity_browser/bwutils/metadata/metadata.py | 109 +++++++++++++---- activity_browser/bwutils/settings.py | 10 +- activity_browser/ui/widgets/text_edit.py | 6 + 7 files changed, 225 insertions(+), 26 deletions(-) create mode 100644 activity_browser/app/pages/settings/metadatastore.py diff --git a/activity_browser/app/pages/settings/__init__.py b/activity_browser/app/pages/settings/__init__.py index bffc24319..491f1ff24 100644 --- a/activity_browser/app/pages/settings/__init__.py +++ b/activity_browser/app/pages/settings/__init__.py @@ -4,6 +4,7 @@ from .startup import StartupSettingsChapter from .appearance import AppearanceSettingsChapter from .project_manager import ProjectManagerSettingsChapter +from .metadatastore import MetadataStoreSettingsChapter __all__ = [ "SettingsPage", @@ -11,4 +12,5 @@ "StartupSettingsChapter", "AppearanceSettingsChapter", "ProjectManagerSettingsChapter", + "MetadataStoreSettingsChapter", ] diff --git a/activity_browser/app/pages/settings/metadatastore.py b/activity_browser/app/pages/settings/metadatastore.py new file mode 100644 index 000000000..859fa6ca4 --- /dev/null +++ b/activity_browser/app/pages/settings/metadatastore.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +from loguru import logger +from qtpy import QtWidgets + +from activity_browser.app import settings +from activity_browser.app.pages.settings.base import BaseSettingsChapter + + +class MetadataStoreSettingsChapter(BaseSettingsChapter): + """Chapter for metadatastore-related settings.""" + + def __init__(self, parent=None): + super().__init__(parent) + + # Caching enabled checkbox + self.caching_checkbox = QtWidgets.QCheckBox("Enable caching") + self.caching_checkbox.setToolTip( + "Enable caching for faster data access. " + "Disable if you experience memory issues or want to force fresh data loading." + ) + + # Searcher enabled checkbox + self.searcher_checkbox = QtWidgets.QCheckBox("Enable searcher") + self.searcher_checkbox.setToolTip( + "Enable the full-text search functionality for activities and metadata. " + "Disable if you experience performance issues with large databases." + ) + + self.build_layout() + self.connect_signals() + self.reset() + + def connect_signals(self): + """Connect signals and slots.""" + # Emit changed signal when settings change + self.caching_checkbox.stateChanged.connect(lambda: self.changed.emit()) + self.searcher_checkbox.stateChanged.connect(lambda: self.changed.emit()) + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Metadata store group + metadatastore_group = QtWidgets.QGroupBox("Metadata Store Options") + metadatastore_layout = QtWidgets.QVBoxLayout() + + metadatastore_layout.addWidget(self.caching_checkbox) + metadatastore_layout.addWidget(self.searcher_checkbox) + + # Add description label + description = QtWidgets.QLabel( + "These settings control the behavior of the metadata store, " + "which manages activity and exchange metadata for improved performance." + ) + description.setWordWrap(True) + description.setStyleSheet("color: gray; font-size: 10pt;") + metadatastore_layout.addWidget(description) + + metadatastore_group.setLayout(metadatastore_layout) + + layout.addWidget(metadatastore_group) + layout.addStretch() + + self.setLayout(layout) + + # --- Settings management methods --- # + def reset(self): + """(Re)set to initial values.""" + try: + self.caching_checkbox.setChecked( + settings["metadatastore"]["caching_enabled"] + ) + self.searcher_checkbox.setChecked( + settings["metadatastore"]["searcher_enabled"] + ) + except (KeyError, TypeError): + # Use defaults if settings don't exist yet + self.caching_checkbox.setChecked(True) + self.searcher_checkbox.setChecked(True) + + def has_changes(self): + """Check if there are unsaved changes.""" + try: + current_state = { + 'caching_enabled': self.caching_checkbox.isChecked(), + 'searcher_enabled': self.searcher_checkbox.isChecked(), + } + initial_state = { + 'caching_enabled': settings["metadatastore"]["caching_enabled"], + 'searcher_enabled': settings["metadatastore"]["searcher_enabled"], + } + return current_state != initial_state + except (KeyError, TypeError): + # If settings don't exist, check against defaults + return (self.caching_checkbox.isChecked() != True or + self.searcher_checkbox.isChecked() != True) + + def set_settings(self): + """Save metadatastore settings.""" + if "metadatastore" not in settings.global_config: + settings.global_config["metadatastore"] = {} + + settings.global_config["metadatastore"]["caching_enabled"] = self.caching_checkbox.isChecked() + settings.global_config["metadatastore"]["searcher_enabled"] = self.searcher_checkbox.isChecked() + + logger.info( + f"Metadatastore settings saved: " + f"caching={self.caching_checkbox.isChecked()}, " + f"searcher={self.searcher_checkbox.isChecked()}" + ) + diff --git a/activity_browser/app/pages/settings/settings_page.py b/activity_browser/app/pages/settings/settings_page.py index ed811d3c2..7a115640b 100644 --- a/activity_browser/app/pages/settings/settings_page.py +++ b/activity_browser/app/pages/settings/settings_page.py @@ -11,6 +11,7 @@ from .startup import StartupSettingsChapter from .appearance import AppearanceSettingsChapter from .project_manager import ProjectManagerSettingsChapter +from .metadatastore import MetadataStoreSettingsChapter class SettingsPage(QtWidgets.QWidget): @@ -37,12 +38,14 @@ def __init__(self, parent=None): self.startup_chapter = StartupSettingsChapter(self) self.appearance_chapter = AppearanceSettingsChapter(self) self.project_manager_chapter = ProjectManagerSettingsChapter(self) - + self.metadatastore_chapter = MetadataStoreSettingsChapter(self) + # Add chapters to the stack self.chapters = [ ("Startup", self.startup_chapter), ("Appearance", self.appearance_chapter), ("Projects", self.project_manager_chapter), + ("Metadata Store", self.metadatastore_chapter), ] for name, widget in self.chapters: diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 7992aeef4..7533feac4 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -8,6 +8,8 @@ from qtpy.QtCore import QObject, QThread, Signal, SignalInstance +from activity_browser.bwutils.settings import Settings + from .metadata import MetaDataStore from .fields import secondary_types, primary, secondary, search_engine_whitelist, all_fields @@ -42,7 +44,7 @@ def load_project(self): self.secondary_status = "loading" # check for valid cache and load from it if available - if self._has_cache(): + if self._has_cache() and Settings()["metadatastore"]["caching_enabled"]: self.cache_load_project() return @@ -266,6 +268,10 @@ def run(self): logger.debug("Skipping searcher initialization due to AB_NO_SEARCHER environment variable") return + if Settings()["metadatastore"]["searcher_enabled"] is False: + logger.debug("Skipping searcher initialization due to settings") + return + if self.mds.searcher is not None: old_searcher = self.mds.searcher self.mds.searcher = None diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 1d8b92233..5cd296c18 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -5,6 +5,7 @@ import pandas as pd +from activity_browser.bwutils.settings import Settings from .fields import all_fields, all_types @@ -101,22 +102,22 @@ def flush_mutations(self) -> tuple[set[tuple[str, str]], set[tuple[str, str]], s self._updated.clear() self._deleted.clear() - cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" - self._dataframe.to_pickle(cache_path) + if Settings()["metadatastore"]["caching_enabled"]: + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + self._dataframe.to_pickle(cache_path) return added, updated, deleted def match(self, **kwargs: dict[str, str]) -> pd.DataFrame: """Return a slice of the dataframe matching the criteria. """ - with self._df_lock: - df = self._dataframe.query( - " and ".join( - [ - f"`{key}`.astype('str') == {str(value)!r}" if not pd.isna(value) else f"`{key}`.isnull()" - for key, value in kwargs.items() - ]) - ) + df = self._dataframe.query( + " and ".join( + [ + f"`{key}`.astype('str') == {str(value)!r}" if not pd.isna(value) else f"`{key}`.isnull()" + for key, value in kwargs.items() + ]) + ) return df @@ -142,23 +143,85 @@ def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFr df = self._dataframe.loc[[db_name], columns] return df.reindex(columns, axis="columns") + def _pandas_search(self, query: str, database: str = None, columns: list = None) -> pd.DataFrame: + """Fallback pandas-based search when searcher is not initialized. + + Args: + query: Search query string, may contain key:value parameters + database: Optional database name to restrict search + columns: Optional list of columns to return + + Returns: + DataFrame with matching results + """ + params, clean_query = get_query_parameters(query) + columns = columns if columns is not None else all_fields + + # Start with the full dataframe or database subset + if database and database in self.databases: + df = self._dataframe.loc[[database]] + else: + df = self._dataframe + + if not clean_query.strip(): + # If no search query, just filter by parameters + if params: + extra_query = " & ".join( + [ + f"`{key}`.astype('str').str.contains('{value}', case=False)" + for key, value in params.items() + if key in df.columns + ] + ) + if extra_query: + df = df.query(extra_query) + return df[columns] + + # Search across text fields: name, product, synonyms, categories, unit, location + search_fields = ['name', 'product', 'synonyms', 'categories', 'unit', 'location', 'CAS number'] + mask = pd.Series([False] * len(df), index=df.index) + + for field in search_fields: + if field in df.columns: + # Case-insensitive search + mask |= df[field].astype(str).str.contains(clean_query, case=False, na=False) + + df = df[mask] + + # Apply additional parameter filters if any + if params: + extra_query = " & ".join( + [ + f"`{key}`.astype('str').str.contains('{value}', case=False)" + for key, value in params.items() + if key in df.columns + ] + ) + if extra_query: + df = df.query(extra_query) + + return df[columns] if columns else df + def search(self, query: str, columns: list = None) -> pd.DataFrame: - if not self.searcher: - logger.warning(f"Attempted to search metadata before searcher was initialized.") - return pd.DataFrame(columns=columns or all_fields) + if self.searcher: + # Advanced searcher is initialized, so use that + params, query = get_query_parameters(query) + result = self.searcher.search(query) + return self._meta_from_result(params, result, columns) - params, query = get_query_parameters(query) - result = self.searcher.search(query) - return self._meta_from_result(params, result, columns) + # Fallback to simple pandas search + logger.debug("Using simple pandas search as searcher is not initialized.") + return self._pandas_search(query, columns=columns) def search_database(self, query: str, database: str, columns: list = None) -> pd.DataFrame: - if not self.searcher: - logger.warning(f"Attempted to search metadata before searcher was initialized.") - return pd.DataFrame(columns=columns or all_fields) - - params, query = get_query_parameters(query) - result = self.searcher.fuzzy_search(query, database=database) - return self._meta_from_result(params, result, columns) + if self.searcher: + params, query = get_query_parameters(query) + result = self.searcher.fuzzy_search(query, database=database) + return self._meta_from_result(params, result, columns) + + # Fallback to simple pandas search + logger.debug(f"Using simple pandas search for database '{database}' as searcher is not initialized.") + return self._pandas_search(query, database=database, columns=columns) def _meta_from_result(self, params: dict, result: list[int], columns: list = None) -> pd.DataFrame: df = self._dataframe.loc[self.dataframe["id"].isin(result), columns or all_fields] diff --git a/activity_browser/bwutils/settings.py b/activity_browser/bwutils/settings.py index 3051f1de9..35f43e454 100644 --- a/activity_browser/bwutils/settings.py +++ b/activity_browser/bwutils/settings.py @@ -17,6 +17,10 @@ "appearance": { "theme": "default", "pane_tab_position": "bottom", + }, + "metadatastore": { + "caching_enabled": True, + "searcher_enabled": True, } } @@ -52,7 +56,11 @@ def __getitem__(self, key): return self.virtual_config[key] if key in self.project_config: return self.project_config[key] - return self.global_config[key] + if key in self.global_config: + return self.global_config[key] + if key in defaults: + return defaults[key] + raise KeyError(f"Setting '{key}' not found in any configuration level.") def __setitem__(self, key, value): if isinstance(key, tuple): diff --git a/activity_browser/ui/widgets/text_edit.py b/activity_browser/ui/widgets/text_edit.py index a665376a8..7c1a20a4f 100644 --- a/activity_browser/ui/widgets/text_edit.py +++ b/activity_browser/ui/widgets/text_edit.py @@ -174,6 +174,9 @@ def __init__(self, parent=None): self.database_name = "" def _sanitize_input(self): + if not self.mds.searcher: + return + self._debounce_timer.stop() text = self.toPlainText() clean_text = self.mds.searcher.ONE_SPACE_PATTERN.sub(" ", text) @@ -198,6 +201,9 @@ def _sanitize_input(self): self._set_debounce() def _set_autocomplete_items(self): + if not self.mds.searcher: + return + text = self.toPlainText() if text.startswith("="): self.model.setStringList([]) From e044d01cc5b967b166cd4e9b1f14fea08cf6c204 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 11 Dec 2025 17:36:26 +0100 Subject: [PATCH 243/267] Clear metadatastore cache from settings --- activity_browser/app/actions/__init__.py | 1 + .../app/actions/metadatastore_cache_clear.py | 20 +++++++++++++++++++ .../app/actions/project/project_switch.py | 4 ++-- .../app/pages/settings/metadatastore.py | 16 ++++++++++++++- activity_browser/bwutils/metadata/metadata.py | 10 ++++++++++ 5 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 activity_browser/app/actions/metadatastore_cache_clear.py diff --git a/activity_browser/app/actions/__init__.py b/activity_browser/app/actions/__init__.py index 6ca690aaa..b7ccc729d 100644 --- a/activity_browser/app/actions/__init__.py +++ b/activity_browser/app/actions/__init__.py @@ -97,3 +97,4 @@ from .metadatastore_open import MetaDataStoreOpen from .node_select_open import NodeSelectOpen from .save_parameters_to_excel import SaveParametersToExcel +from .metadatastore_cache_clear import MetaDataStoreCacheClear diff --git a/activity_browser/app/actions/metadatastore_cache_clear.py b/activity_browser/app/actions/metadatastore_cache_clear.py new file mode 100644 index 000000000..30fd619c1 --- /dev/null +++ b/activity_browser/app/actions/metadatastore_cache_clear.py @@ -0,0 +1,20 @@ +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + +import bw2data as bd + +from .project.project_switch import ProjectSwitch + + +class MetaDataStoreCacheClear(ABAction): + + icon = qicons.right + text = "Clear Metadata Store Cache" + tool_tip = "Clear the Metadata Store cache and reload the current project" + + @staticmethod + @exception_dialogs + def run(): + app.metadata.clear_cache() + ProjectSwitch.run(bd.projects.current, reload=True) diff --git a/activity_browser/app/actions/project/project_switch.py b/activity_browser/app/actions/project/project_switch.py index b6e8abe48..a7a9aa589 100644 --- a/activity_browser/app/actions/project/project_switch.py +++ b/activity_browser/app/actions/project/project_switch.py @@ -37,9 +37,9 @@ class ProjectSwitch(ABAction): @staticmethod @exception_dialogs - def run(project_name: str): + def run(project_name: str, reload: bool = False): # compare the new to the current project name and switch to the new one if the two are not the same - if project_name == bd.projects.current: + if project_name == bd.projects.current and not reload: logger.debug(f"Brightway2 already selected: {project_name}") return diff --git a/activity_browser/app/pages/settings/metadatastore.py b/activity_browser/app/pages/settings/metadatastore.py index 859fa6ca4..7659d4239 100644 --- a/activity_browser/app/pages/settings/metadatastore.py +++ b/activity_browser/app/pages/settings/metadatastore.py @@ -3,6 +3,7 @@ from qtpy import QtWidgets from activity_browser.app import settings +from activity_browser.app.actions.metadatastore_cache_clear import MetaDataStoreCacheClear from activity_browser.app.pages.settings.base import BaseSettingsChapter @@ -26,6 +27,13 @@ def __init__(self, parent=None): "Disable if you experience performance issues with large databases." ) + # Clear cache button + self.clear_cache_button = QtWidgets.QPushButton("Clear Cache") + self.clear_cache_button.setToolTip( + "Clear the metadata store cache and reload the current project. " + "Use this if you experience issues with outdated or corrupted cache data." + ) + self.build_layout() self.connect_signals() self.reset() @@ -36,6 +44,9 @@ def connect_signals(self): self.caching_checkbox.stateChanged.connect(lambda: self.changed.emit()) self.searcher_checkbox.stateChanged.connect(lambda: self.changed.emit()) + # Connect clear cache button + self.clear_cache_button.clicked.connect(MetaDataStoreCacheClear.run) + def build_layout(self): """Build the chapter layout.""" layout = QtWidgets.QVBoxLayout() @@ -47,10 +58,13 @@ def build_layout(self): metadatastore_layout.addWidget(self.caching_checkbox) metadatastore_layout.addWidget(self.searcher_checkbox) + # Add clear cache button + metadatastore_layout.addWidget(self.clear_cache_button) + # Add description label description = QtWidgets.QLabel( "These settings control the behavior of the metadata store, " - "which manages activity and exchange metadata for improved performance." + "which manages activity data for improved performance." ) description.setWordWrap(True) description.setStyleSheet("color: gray; font-size: 10pt;") diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 5cd296c18..4d63b315c 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -248,6 +248,16 @@ def auto_complete(self, word: str, context: Optional[set] = None, database: Opti completions = self.searcher.auto_complete(word, context=context, database=database) return completions + def clear_cache(self): + from activity_browser.bwutils import filesystem + + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + if cache_path.exists(): + cache_path.unlink() + logger.info("Metadata store cache cleared.") + else: + logger.info("No metadata store cache found to clear.") + def get_query_parameters(query: str) -> tuple[dict[str, str], str]: """Extract key-value pairs from a query string of the form 'key1:value1 key2:value2'.""" From 3d74fa859bebfe3df0753abc127f7177798f9e22 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 11 Dec 2025 17:40:52 +0100 Subject: [PATCH 244/267] Reloading project using F5 --- .../app/actions/metadatastore_open.py | 2 -- .../app/actions/project/project_switch.py | 18 ++++++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/activity_browser/app/actions/metadatastore_open.py b/activity_browser/app/actions/metadatastore_open.py index d96a57717..51d6b93ca 100644 --- a/activity_browser/app/actions/metadatastore_open.py +++ b/activity_browser/app/actions/metadatastore_open.py @@ -1,5 +1,3 @@ -from loguru import logger - from activity_browser import app from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui.icons import qicons diff --git a/activity_browser/app/actions/project/project_switch.py b/activity_browser/app/actions/project/project_switch.py index a7a9aa589..9e3268b59 100644 --- a/activity_browser/app/actions/project/project_switch.py +++ b/activity_browser/app/actions/project/project_switch.py @@ -7,6 +7,7 @@ from activity_browser import app from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.core.application import global_shortcut from .project_migrate25 import ProjectMigrate25 @@ -43,7 +44,7 @@ def run(project_name: str, reload: bool = False): logger.debug(f"Brightway2 already selected: {project_name}") return - dialog = ProjectChangeDialog(project_name, app.main_window) + dialog = ProjectChangeDialog(project_name, reload, app.main_window) dialog.show() app.application.processEvents() @@ -67,14 +68,23 @@ def run(project_name: str, reload: bool = False): def set_warning_bar(): app.main_window.addToolBar(ProjectWarningBar()) + @global_shortcut("F5") + @staticmethod + def reload_project(): + ProjectSwitch.run(bd.projects.current, reload=True) + class ProjectChangeDialog(QtWidgets.QDialog): - def __init__(self, project_name: str, parent=None): + def __init__(self, project_name: str, reload: bool, parent=None): super().__init__(parent, QtCore.Qt.WindowTitleHint) - self.setWindowTitle(f"Switching project") + + title = "Reloading project" if reload else "Switching project" + subtitle = f"Reloading project: {project_name}" if reload else f"Switching to project: {project_name}" + + self.setWindowTitle(title) self.setModal(True) - self.label = QtWidgets.QLabel(f"Switching to project: {project_name}", self) + self.label = QtWidgets.QLabel(subtitle, self) layout = QtWidgets.QVBoxLayout(self) layout.addWidget(self.label) From fd8472dce72d8047b2b4cda1afb80b374048fe00 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Thu, 11 Dec 2025 17:42:40 +0100 Subject: [PATCH 245/267] Bump bw_functional --- pyproject.toml | 2 +- recipe/meta.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b76791a7e..c6a80f712 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "bw_simapro_csv >=0.2.6", "ecoinvent_interface", "matrix_utils>=0.5", - "bw-functional==0b94", + "bw-functional==0b97", "networkx", "numpy>=1.23.5,<2", "pandas>=2.2.1", diff --git a/recipe/meta.yaml b/recipe/meta.yaml index f8b4c3470..82364aabd 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -27,7 +27,7 @@ requirements: - bw_graph_tools >=0.5 - bw_processing >=1.0 - bw_simapro_csv >=0.2.6 - - bw_functional=0.b.94 + - bw_functional=0.b.97 - ecoinvent_interface - matrix_utils >=0.5 - numpy >=1.23.5, <2 From 9c948b50b4bee46bf922b090426e87a80bafaa3f Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 10:17:59 +0100 Subject: [PATCH 246/267] Plugin settings and loading --- activity_browser/__main__.py | 16 ++ activity_browser/app/pages/settings/README.md | 18 ++ .../app/pages/settings/__init__.py | 2 + .../app/pages/settings/plugins.py | 166 ++++++++++++++++++ .../app/pages/settings/settings_page.py | 3 + activity_browser/bwutils/settings.py | 20 ++- 6 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 activity_browser/app/pages/settings/plugins.py diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index a84595500..0e7073f61 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -95,6 +95,9 @@ def load_layout(self): def load_finished(self): from activity_browser import app + + load_plugins() + app.main_window.show() self.deleteLater() @@ -132,6 +135,8 @@ def run_activity_browser_no_launcher(): from .ui.widgets import CentralTabWidget from .app import panes, pages, application, metadata + load_plugins() + application.main_window.show() application.set_icon() # setting this here seems to fix the icon not showing sometimes @@ -197,6 +202,17 @@ def check_pypi_update(): "pip install --upgrade activity-browser\n\n" "Press any key to continue without updating...\033[0m") +def load_plugins(): + from activity_browser.bwutils.settings import Settings + settings = Settings() + plugins = settings["plugins"].get("enabled_plugins", []) + for plugin in plugins: + try: + __import__(plugin) + logger.info(f"Successfully loaded plugin: {plugin}") + except ImportError: + logger.warning(f"Could not load plugin: {plugin}") + if "--no-launcher" in sys.argv: run_activity_browser_no_launcher() diff --git a/activity_browser/app/pages/settings/README.md b/activity_browser/app/pages/settings/README.md index 2fee9d01d..830bf407f 100644 --- a/activity_browser/app/pages/settings/README.md +++ b/activity_browser/app/pages/settings/README.md @@ -11,6 +11,9 @@ settings/ ├── base.py # BaseSettingsChapter (base class for all chapters) ├── startup.py # StartupSettingsChapter ├── appearance.py # AppearanceSettingsChapter +├── project_manager.py # ProjectManagerSettingsChapter +├── metadatastore.py # MetadataStoreSettingsChapter +├── plugins.py # PluginsSettingsChapter └── README.md # This file ``` @@ -158,6 +161,21 @@ Manages: - Theme selection (Light/Dark) - Future: Font sizes, colors, etc. +### ProjectManagerSettingsChapter (`project_manager.py`) +Manages: +- Project management settings +- Project creation and deletion + +### MetadataStoreSettingsChapter (`metadatastore.py`) +Manages: +- Metadata store caching settings +- Searcher configuration + +### PluginsSettingsChapter (`plugins.py`) +Manages: +- List of enabled Python plugins +- Add/remove plugin packages that should be imported at startup + ## Testing Test the settings page with: diff --git a/activity_browser/app/pages/settings/__init__.py b/activity_browser/app/pages/settings/__init__.py index 491f1ff24..521c047d9 100644 --- a/activity_browser/app/pages/settings/__init__.py +++ b/activity_browser/app/pages/settings/__init__.py @@ -5,6 +5,7 @@ from .appearance import AppearanceSettingsChapter from .project_manager import ProjectManagerSettingsChapter from .metadatastore import MetadataStoreSettingsChapter +from .plugins import PluginsSettingsChapter __all__ = [ "SettingsPage", @@ -13,4 +14,5 @@ "AppearanceSettingsChapter", "ProjectManagerSettingsChapter", "MetadataStoreSettingsChapter", + "PluginsSettingsChapter", ] diff --git a/activity_browser/app/pages/settings/plugins.py b/activity_browser/app/pages/settings/plugins.py new file mode 100644 index 000000000..bd743a624 --- /dev/null +++ b/activity_browser/app/pages/settings/plugins.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +import importlib.util +from loguru import logger +from qtpy import QtWidgets + +from activity_browser.app import settings +from activity_browser.app.pages.settings.base import BaseSettingsChapter +from activity_browser.ui.icons import qicons + + +class PluginsSettingsChapter(BaseSettingsChapter): + """Chapter for plugin-related settings.""" + + def __init__(self, parent=None): + super().__init__(parent) + + # List widget to display enabled plugins + self.plugin_list = QtWidgets.QListWidget() + self.plugin_list.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + + # Input field for adding new plugins + self.plugin_input = QtWidgets.QLineEdit() + self.plugin_input.setPlaceholderText("Enter Python package name (e.g., my_plugin)") + + # Buttons + self.add_button = QtWidgets.QPushButton("Add") + self.remove_button = QtWidgets.QPushButton("Remove") + self.remove_button.setEnabled(False) + + self.build_layout() + self.connect_signals() + self.reset() + + def connect_signals(self): + """Connect signals and slots.""" + self.add_button.clicked.connect(self.add_plugin) + self.remove_button.clicked.connect(self.remove_plugin) + self.plugin_input.returnPressed.connect(self.add_plugin) + self.plugin_list.itemSelectionChanged.connect(self.on_selection_changed) + self.plugin_list.model().rowsInserted.connect(lambda: self.changed.emit()) + self.plugin_list.model().rowsRemoved.connect(lambda: self.changed.emit()) + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Plugin list section + plugin_group = QtWidgets.QGroupBox("Enabled Plugins") + plugin_layout = QtWidgets.QVBoxLayout() + + # Description label + description = QtWidgets.QLabel( + "Add Python packages that should be imported as plugins.\n" + "These packages will be loaded when Activity Browser starts." + ) + description.setWordWrap(True) + plugin_layout.addWidget(description) + + # List widget + plugin_layout.addWidget(self.plugin_list) + + # Input section + input_layout = QtWidgets.QHBoxLayout() + input_layout.addWidget(self.plugin_input) + input_layout.addWidget(self.add_button) + plugin_layout.addLayout(input_layout) + + # Remove button + plugin_layout.addWidget(self.remove_button) + + plugin_group.setLayout(plugin_layout) + + layout.addWidget(plugin_group) + layout.addStretch() + + self.setLayout(layout) + + def on_selection_changed(self): + """Enable/disable remove button based on selection.""" + self.remove_button.setEnabled(len(self.plugin_list.selectedItems()) > 0) + + def module_exists(self, module_name): + """Check if a module can be found/imported.""" + try: + spec = importlib.util.find_spec(module_name) + return spec is not None + except (ImportError, ModuleNotFoundError, ValueError, AttributeError): + return False + + def add_plugin_to_list(self, plugin_name): + """Add a plugin to the list widget with appropriate icon.""" + item = QtWidgets.QListWidgetItem(plugin_name) + + # Check if module exists and add warning icon if not + if not self.module_exists(plugin_name): + # Use standard warning icon + icon = qicons.critical + item.setIcon(icon) + item.setToolTip(f"Warning: Module '{plugin_name}' not found. " + "Make sure it is installed before starting Activity Browser.") + logger.warning(f"Plugin module '{plugin_name}' not found") + else: + icon = qicons.empty + item.setIcon(icon) + item.setToolTip(f"Module '{plugin_name}' is available") + + self.plugin_list.addItem(item) + + def add_plugin(self): + """Add a plugin to the list.""" + plugin_name = self.plugin_input.text().strip() + if not plugin_name: + return + + # Check if plugin already exists + existing_items = [self.plugin_list.item(i).text() for i in range(self.plugin_list.count())] + if plugin_name in existing_items: + QtWidgets.QMessageBox.warning( + self, + "Duplicate Plugin", + f"The plugin '{plugin_name}' is already in the list." + ) + return + + # Add to list with icon + self.add_plugin_to_list(plugin_name) + self.plugin_input.clear() + logger.debug(f"Added plugin: {plugin_name}") + self.changed.emit() + + def remove_plugin(self): + """Remove selected plugin from the list.""" + selected_items = self.plugin_list.selectedItems() + if not selected_items: + return + + for item in selected_items: + plugin_name = item.text() + row = self.plugin_list.row(item) + self.plugin_list.takeItem(row) + logger.debug(f"Removed plugin: {plugin_name}") + + self.changed.emit() + + # --- Settings management methods --- # + def reset(self): + """(Re)set to initial values.""" + self.plugin_list.clear() + enabled_plugins = settings["plugins"].get("enabled_plugins", []) + for plugin in enabled_plugins: + self.add_plugin_to_list(plugin) + self.plugin_input.clear() + self.remove_button.setEnabled(False) + + def has_changes(self): + """Check if there are unsaved changes.""" + current_plugins = [self.plugin_list.item(i).text() for i in range(self.plugin_list.count())] + saved_plugins = settings["plugins"].get("enabled_plugins", []) + return current_plugins != saved_plugins + + def set_settings(self): + """Save plugin settings.""" + current_plugins = [self.plugin_list.item(i).text() for i in range(self.plugin_list.count())] + settings["plugins"]["enabled_plugins"] = current_plugins + logger.info(f"Saved enabled plugins: {current_plugins}") + diff --git a/activity_browser/app/pages/settings/settings_page.py b/activity_browser/app/pages/settings/settings_page.py index 7a115640b..ffbe139c3 100644 --- a/activity_browser/app/pages/settings/settings_page.py +++ b/activity_browser/app/pages/settings/settings_page.py @@ -12,6 +12,7 @@ from .appearance import AppearanceSettingsChapter from .project_manager import ProjectManagerSettingsChapter from .metadatastore import MetadataStoreSettingsChapter +from .plugins import PluginsSettingsChapter class SettingsPage(QtWidgets.QWidget): @@ -39,6 +40,7 @@ def __init__(self, parent=None): self.appearance_chapter = AppearanceSettingsChapter(self) self.project_manager_chapter = ProjectManagerSettingsChapter(self) self.metadatastore_chapter = MetadataStoreSettingsChapter(self) + self.plugins_chapter = PluginsSettingsChapter(self) # Add chapters to the stack self.chapters = [ @@ -46,6 +48,7 @@ def __init__(self, parent=None): ("Appearance", self.appearance_chapter), ("Projects", self.project_manager_chapter), ("Metadata Store", self.metadatastore_chapter), + ("Plugins", self.plugins_chapter), ] for name, widget in self.chapters: diff --git a/activity_browser/bwutils/settings.py b/activity_browser/bwutils/settings.py index 35f43e454..43f54a161 100644 --- a/activity_browser/bwutils/settings.py +++ b/activity_browser/bwutils/settings.py @@ -1,4 +1,4 @@ -import os +import copy import json import bw2data as bd import bw2data.signals as bw_signals @@ -21,6 +21,9 @@ "metadatastore": { "caching_enabled": True, "searcher_enabled": True, + }, + "plugins": { + "enabled_plugins": [], } } @@ -86,7 +89,7 @@ def save(self): def load_global_settings(self): global_path = get_appdata_path() / "settings.json" - self.global_config = json.load(open(global_path)) if global_path.exists() else defaults.copy() + self.global_config = json.load(open(global_path)) if global_path.exists() else copy.deepcopy(defaults) def load_project_settings(self, *args, **kwargs): project_path = get_project_ab_path() / "settings.json" @@ -95,10 +98,13 @@ def load_project_settings(self, *args, **kwargs): def load_virtual_settings(self): pass # Implementation later based on environment variables - def reset_to_defaults(self): - self.global_config.read_dict(defaults) - self.global_config.write(open(get_appdata_path() / "settings.ini", "w")) + def restore_defaults(self): + self.global_config = copy.deepcopy(defaults) + global_path = get_appdata_path() / "settings.json" + json.dump(self.global_config, open(global_path, "w"), indent=4) + + self.project_config = {} + project_path = get_project_ab_path() / "settings.json" + project_path.unlink(missing_ok=True) - os.remove(get_project_ab_path() / "settings.ini") - self.load_project_settings() From 7a586cbba4066e44eb823c03322232dfdb588465 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 14:22:59 +0100 Subject: [PATCH 247/267] Building executable action --- .github/workflows/build-executable.yml | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/build-executable.yml diff --git a/.github/workflows/build-executable.yml b/.github/workflows/build-executable.yml new file mode 100644 index 000000000..b61966d6c --- /dev/null +++ b/.github/workflows/build-executable.yml @@ -0,0 +1,45 @@ +name: build-executable.yml +on: + push: + branches: [ major ] + tags: '*' + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ['windows-latest', 'ubuntu-latest, 'macos-latest'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.12 + + - name: Install UV + uses: astral-sh/setup-uv@v2 + + - name: Sync dependencies + shell: bash + run: | + uv add pyinstaller + uv sync --prerelease=allow + + - name: Build executable with PyInstaller + shell: bash + run: | + pyinstaller --onefile --name activity-browser activity_browser/__main__.py + + - uses: actions/upload-artifact@v2 + with: + path: dist/* \ No newline at end of file From 96f4ba4787553914db627b28995dae9f088b700a Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 14:24:45 +0100 Subject: [PATCH 248/267] Building executable action --- .github/workflows/build-executable.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/build-executable.yml b/.github/workflows/build-executable.yml index b61966d6c..eb8a33bec 100644 --- a/.github/workflows/build-executable.yml +++ b/.github/workflows/build-executable.yml @@ -6,12 +6,7 @@ on: jobs: build: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: ['windows-latest', 'ubuntu-latest, 'macos-latest'] - + runs-on: ['windows-latest', 'ubuntu-latest', 'macos-latest'] steps: - name: Checkout code uses: actions/checkout@v4 From b2e31f1066eb3449785a999f24938305946f3c8a Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 14:29:56 +0100 Subject: [PATCH 249/267] Building executable action --- .github/workflows/build-executable.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-executable.yml b/.github/workflows/build-executable.yml index eb8a33bec..bca1e10b9 100644 --- a/.github/workflows/build-executable.yml +++ b/.github/workflows/build-executable.yml @@ -1,4 +1,4 @@ -name: build-executable.yml +name: Build Executable on: push: branches: [ major ] @@ -6,7 +6,10 @@ on: jobs: build: - runs-on: ['windows-latest', 'ubuntu-latest', 'macos-latest'] + strategy: + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -37,4 +40,4 @@ jobs: - uses: actions/upload-artifact@v2 with: - path: dist/* \ No newline at end of file + path: dist/* From d574b2835874388eab55a813d48cc6b6b4843587 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 14:30:45 +0100 Subject: [PATCH 250/267] Building executable action --- .github/workflows/build-executable.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-executable.yml b/.github/workflows/build-executable.yml index bca1e10b9..ecf3205f1 100644 --- a/.github/workflows/build-executable.yml +++ b/.github/workflows/build-executable.yml @@ -38,6 +38,6 @@ jobs: run: | pyinstaller --onefile --name activity-browser activity_browser/__main__.py - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v4 with: path: dist/* From ed22cb53229028d7f9b0443dae28b92b750d9e69 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 14:33:16 +0100 Subject: [PATCH 251/267] Building executable action --- .github/workflows/build-executable.yml | 2 +- .github/workflows/testing.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-executable.yml b/.github/workflows/build-executable.yml index ecf3205f1..e4aa92556 100644 --- a/.github/workflows/build-executable.yml +++ b/.github/workflows/build-executable.yml @@ -36,7 +36,7 @@ jobs: - name: Build executable with PyInstaller shell: bash run: | - pyinstaller --onefile --name activity-browser activity_browser/__main__.py + uv run python -m pyinstaller --onefile --name activity-browser activity_browser/__main__.py - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index a0d5bee6e..adc468289 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -3,9 +3,9 @@ on: pull_request: branches: - major - push: - branches: - - major +# push: +# branches: +# - major jobs: tests: From 9dccdb60993546faf63b90f66f2f01716642edf0 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 14:36:03 +0100 Subject: [PATCH 252/267] Building executable action --- .github/workflows/build-executable.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-executable.yml b/.github/workflows/build-executable.yml index e4aa92556..239a9f106 100644 --- a/.github/workflows/build-executable.yml +++ b/.github/workflows/build-executable.yml @@ -36,7 +36,7 @@ jobs: - name: Build executable with PyInstaller shell: bash run: | - uv run python -m pyinstaller --onefile --name activity-browser activity_browser/__main__.py + uv run pyinstaller --onefile --name activity-browser activity_browser/__main__.py - uses: actions/upload-artifact@v4 with: From 0b343a336562ffa99bc9847e4fb388bf15533415 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 14:42:28 +0100 Subject: [PATCH 253/267] Building executable action --- .github/workflows/build-executable.yml | 3 +- pyinstaller.spec | 49 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 pyinstaller.spec diff --git a/.github/workflows/build-executable.yml b/.github/workflows/build-executable.yml index 239a9f106..8334d38b2 100644 --- a/.github/workflows/build-executable.yml +++ b/.github/workflows/build-executable.yml @@ -7,6 +7,7 @@ on: jobs: build: strategy: + fail-fast: false matrix: os: [windows-latest, ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} @@ -36,7 +37,7 @@ jobs: - name: Build executable with PyInstaller shell: bash run: | - uv run pyinstaller --onefile --name activity-browser activity_browser/__main__.py + uv run pyinstaller pyinstaller.spec - uses: actions/upload-artifact@v4 with: diff --git a/pyinstaller.spec b/pyinstaller.spec new file mode 100644 index 000000000..1959908f8 --- /dev/null +++ b/pyinstaller.spec @@ -0,0 +1,49 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + +a = Analysis( + ['activity_browser/__main__.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[ + 'activity_browser', + 'PySide6', + 'bw2data', + 'bw2io', + 'bw2calc', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='activity-browser', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=None, +) From 6bef201e83c753111a2a02eda452f19f75ef6d6a Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 14:43:18 +0100 Subject: [PATCH 254/267] Building executable action --- pyinstaller.spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyinstaller.spec b/pyinstaller.spec index 1959908f8..a8c030a19 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -3,7 +3,7 @@ block_cipher = None a = Analysis( - ['activity_browser/__main__.py'], + ['run-activity-browser.py'], pathex=[], binaries=[], datas=[], From bd400773b14c3abb9cb22d9c086e1c7f754f2ed3 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 14:56:59 +0100 Subject: [PATCH 255/267] Building executable action --- .github/workflows/build-executable.yml | 5 +++-- .../static/icons/main/activitybrowser.ico | Bin 0 -> 124236 bytes pyinstaller.spec | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 activity_browser/static/icons/main/activitybrowser.ico diff --git a/.github/workflows/build-executable.yml b/.github/workflows/build-executable.yml index 8334d38b2..9d09c9f17 100644 --- a/.github/workflows/build-executable.yml +++ b/.github/workflows/build-executable.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-latest, ubuntu-latest, macos-latest] + os: [windows-latest, ubuntu-latest, macos-latest, macos-15] runs-on: ${{ matrix.os }} steps: - name: Checkout code @@ -41,4 +41,5 @@ jobs: - uses: actions/upload-artifact@v4 with: - path: dist/* + name: activity-browser-${{ matrix.os }} + path: dist/* \ No newline at end of file diff --git a/activity_browser/static/icons/main/activitybrowser.ico b/activity_browser/static/icons/main/activitybrowser.ico new file mode 100644 index 0000000000000000000000000000000000000000..29bb1b917e29666f8f404b86db861ecd395401b2 GIT binary patch literal 124236 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 literal 0 HcmV?d00001 diff --git a/pyinstaller.spec b/pyinstaller.spec index a8c030a19..db7a3712b 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -45,5 +45,5 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, - icon=None, + icon='activity_browser/static/icons/main/activitybrowser.ico', ) From 164e05b69f682aa6b417f3b1dfa7a741d8cd8b70 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 15:12:02 +0100 Subject: [PATCH 256/267] Building executable action --- pyinstaller.spec | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pyinstaller.spec b/pyinstaller.spec index db7a3712b..20d3c295d 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -1,12 +1,16 @@ # -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_data_files block_cipher = None +# Collect all data files from activity_browser package +ab_datas = collect_data_files('activity_browser') + a = Analysis( ['run-activity-browser.py'], pathex=[], binaries=[], - datas=[], + datas=ab_datas, hiddenimports=[ 'activity_browser', 'PySide6', @@ -39,7 +43,7 @@ exe = EXE( upx=True, upx_exclude=[], runtime_tmpdir=None, - console=False, + console=True, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, From 6bea3a238bce306085e0da7c6b134e58fdc50f56 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 15:35:25 +0100 Subject: [PATCH 257/267] Try adding pypardiso dlls --- pyinstaller.spec | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/pyinstaller.spec b/pyinstaller.spec index 20d3c295d..eb0c0e227 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -1,5 +1,22 @@ # -*- mode: python ; coding: utf-8 -*- from PyInstaller.utils.hooks import collect_data_files +import sys +import glob +from pathlib import Path + +def find_mkl_libs(): + """Find MKL DLL files for PyPardiso""" + patterns = [ + f'{sys.prefix}/Library/bin/mkl_*.dll', + f'{sys.prefix}/Library/bin/libiomp5md.dll', + ] + + binaries = [] + for pattern in patterns: + for lib in glob.glob(pattern): + binaries.append((lib, '.')) + + return binaries block_cipher = None @@ -9,7 +26,7 @@ ab_datas = collect_data_files('activity_browser') a = Analysis( ['run-activity-browser.py'], pathex=[], - binaries=[], + binaries=find_mkl_libs(), datas=ab_datas, hiddenimports=[ 'activity_browser', From cfdaab372ae43cf2b543c5eecc9480e12e5f21fc Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 15:54:48 +0100 Subject: [PATCH 258/267] Try adding pypardiso dlls --- .github/workflows/build-executable.yml | 4 +++- pyinstaller.spec | 14 ++------------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-executable.yml b/.github/workflows/build-executable.yml index 9d09c9f17..1174b887d 100644 --- a/.github/workflows/build-executable.yml +++ b/.github/workflows/build-executable.yml @@ -31,12 +31,14 @@ jobs: - name: Sync dependencies shell: bash run: | - uv add pyinstaller uv sync --prerelease=allow + uv pip freeze > dist/requirements-frozen-${{ matrix.os }}.txt - name: Build executable with PyInstaller shell: bash run: | + uv add pyinstaller + uv sync --prerelease=allow uv run pyinstaller pyinstaller.spec - uses: actions/upload-artifact@v4 diff --git a/pyinstaller.spec b/pyinstaller.spec index eb0c0e227..cad168b38 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -4,19 +4,7 @@ import sys import glob from pathlib import Path -def find_mkl_libs(): - """Find MKL DLL files for PyPardiso""" - patterns = [ - f'{sys.prefix}/Library/bin/mkl_*.dll', - f'{sys.prefix}/Library/bin/libiomp5md.dll', - ] - binaries = [] - for pattern in patterns: - for lib in glob.glob(pattern): - binaries.append((lib, '.')) - - return binaries block_cipher = None @@ -34,6 +22,8 @@ a = Analysis( 'bw2data', 'bw2io', 'bw2calc', + 'pypardiso', + 'scikits.umfpack', ], hookspath=[], hooksconfig={}, From 63cf54ba7260114ed6f884975e5c19a0f46cd480 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 15:58:43 +0100 Subject: [PATCH 259/267] Fix setup IC deletion --- .../app/pages/calculation_setup/impact_category_section.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activity_browser/app/pages/calculation_setup/impact_category_section.py b/activity_browser/app/pages/calculation_setup/impact_category_section.py index d3119a331..2fcda22f8 100644 --- a/activity_browser/app/pages/calculation_setup/impact_category_section.py +++ b/activity_browser/app/pages/calculation_setup/impact_category_section.py @@ -65,7 +65,7 @@ class ContextMenu(widgets.ABMenu): @property def selected_ics(self): - return self.parent().model().values_from_indices("name", self.parent().selectedIndexes()) + return list(set([index.row() for index in self.parent().selectedIndexes()])) @property def cs_name(self): From 241cd22939637c34a27af3c518945e268b981612 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 15:59:42 +0100 Subject: [PATCH 260/267] Fix executable building --- .github/workflows/build-executable.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-executable.yml b/.github/workflows/build-executable.yml index 1174b887d..688a9011a 100644 --- a/.github/workflows/build-executable.yml +++ b/.github/workflows/build-executable.yml @@ -31,14 +31,12 @@ jobs: - name: Sync dependencies shell: bash run: | - uv sync --prerelease=allow - uv pip freeze > dist/requirements-frozen-${{ matrix.os }}.txt + uv add pyinstaller + uv sync --prerelease=allow - name: Build executable with PyInstaller shell: bash run: | - uv add pyinstaller - uv sync --prerelease=allow uv run pyinstaller pyinstaller.spec - uses: actions/upload-artifact@v4 From d6c396f74a0bdbeb6861dcbb3f541c089ba22139 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Fri, 12 Dec 2025 16:00:36 +0100 Subject: [PATCH 261/267] Reenable tests --- .github/workflows/testing.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index adc468289..a0d5bee6e 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -3,9 +3,9 @@ on: pull_request: branches: - major -# push: -# branches: -# - major + push: + branches: + - major jobs: tests: From 6cb20346dbeb74fbd2ced7feb27e8b1cd7f544c8 Mon Sep 17 00:00:00 2001 From: Marin Visscher Date: Tue, 16 Dec 2025 11:52:49 +0100 Subject: [PATCH 262/267] Fast calculations on executable --- pyinstaller.spec | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/pyinstaller.spec b/pyinstaller.spec index cad168b38..b45256332 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -1,10 +1,24 @@ # -*- mode: python ; coding: utf-8 -*- -from PyInstaller.utils.hooks import collect_data_files import sys -import glob from pathlib import Path +from PyInstaller.utils.hooks import collect_data_files + +if sys.platform == "win32": + pardiso_deps = [ + "libiomp5md.dll", + "mkl_core.2.dll", + "mkl_intel_thread.2.dll", + "mkl_avx2.2.dll", + "tbbmalloc.dll", + "mkl_vml_avx2.2.dll", + "mkl_rt.2.dll", + ] + bin_dir = Path(sys.prefix) / "Library" / "bin" + binaries = [(str(bin_dir / dll), "lib") for dll in pardiso_deps if (bin_dir / dll).exists()] +else: + binaries = [] block_cipher = None @@ -14,7 +28,7 @@ ab_datas = collect_data_files('activity_browser') a = Analysis( ['run-activity-browser.py'], pathex=[], - binaries=find_mkl_libs(), + binaries=binaries, datas=ab_datas, hiddenimports=[ 'activity_browser', From 9927406151cac7566210d18cc3f7ad4f2d17c7fb Mon Sep 17 00:00:00 2001 From: bsteubing Date: Thu, 19 Mar 2026 14:14:51 -0500 Subject: [PATCH 263/267] Update of the installation documentation to warn users of vens with AB-incompatible Python versions --- docs/getting-started/installation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 1c13a1944..d6d87c68d 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -35,7 +35,7 @@ For more elaborate installing instructions check out the page below for both [in ## Installing from PyPI Installing from the Python Package Index (PyPI) can be done using the standard `pip` command. We strongly recommended installing the Activity Browser into a separate [virtual environment](https://realpython.com/python-virtual-environments-a-primer/) -First make sure you have Python installed on your PC by entering the following command into your terminal or command prompt. +First make sure you have Python installed on your PC by entering the following command into your terminal or command prompt. At this moment the AB is compatible with Python versions 3.10, 3.11, and 3.12. ``` python --version @@ -51,7 +51,7 @@ Afterwards, you need to activate the virtual environment, which differs between ``` C:\Users\me\virtualenvs\ab-beta\Scripts\activate.bat ``` -For a full overview of activation commands, [check out the documentation here](https://docs.python.org/3/library/venv.html#how-venvs-work) +Possibly double-check if the Python version of your virtual environment is compatible with the AB. For a full overview of activation commands, [check out the documentation here](https://docs.python.org/3/library/venv.html#how-venvs-work) ### Activity Browser installation After creating and activating the virtual environment, installing the Beta should be as simple as using the following command: From 0d544d275f879277a78ba36abc6230edb67777ab Mon Sep 17 00:00:00 2001 From: Bernhard Steubing <33026150+bsteubing@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:40:46 -0500 Subject: [PATCH 264/267] Update dependency versions and replace np.NaN with np.nan for consistency (#1644) --- activity_browser/bwutils/metadata/updater.py | 2 +- pyproject.toml | 14 ++++++-------- recipe/meta.yaml | 17 +++++++++-------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index a0fff878f..8e6f7010f 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -36,7 +36,7 @@ def on_signaleddataset_save(self, sender, old, new): return node_data = {f: getattr(new, f) for f in primary} - node_data = node_data | {f: new.data.get(f, np.NaN) for f in secondary} + node_data = node_data | {f: new.data.get(f, np.nan) for f in secondary} node_data["key"] = new.key node_data = pd.Series(node_data, name=new.key) diff --git a/pyproject.toml b/pyproject.toml index 2d09ee7ae..b7bb395b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,30 +33,28 @@ classifiers = [ requires-python = ">=3.10, <3.13" dependencies = [ "arrow", + "bw-functional>=0b97", "bw2analyzer>=0.11.5", "bw2calc>=2.0", "bw2data>=4.1", - "bw2parameters>=1.1", "bw2io>=0.9.3", + "bw2parameters>=1.1", "bw_graph_tools>=0.5", "bw_processing>=1.0", "bw_simapro_csv >=0.2.6", "ecoinvent_interface", + "loguru>=0.7", "matrix_utils>=0.5", - "bw-functional==0b97", "networkx", - "numpy>=1.23.5,<2", + "numpy>=1.23.5,<3", "pandas>=2.2.1", - "pint<=0.21", - "py7zr==0.22.0", + "py7zr>=0.22.0", "pyperclip", - "pyside6>=6.5.0, <6.10", - "pypardiso ; platform_system == 'Windows'", "pyprind", + "pyside6>=6.5.0, <6.10", "qtpy", "salib>=1.4", "seaborn", - "loguru>=0.7", ] diff --git a/recipe/meta.yaml b/recipe/meta.yaml index a828fe1a8..c5e7567ee 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -20,27 +20,28 @@ requirements: run: - python >=3.10, <3.12 - arrow + - bw_functional>=0.b.97 - bw2analyzer >=0.11.5 + - bw2calc >=2.0 - bw2data >=4.1 - - bw2parameters >=1.1 - bw2io >=0.9.3 + - bw2parameters >=1.1 - bw_graph_tools >=0.5 - bw_processing >=1.0 - bw_simapro_csv >=0.2.6 - - bw_functional=0.b.97 - ecoinvent_interface + - loguru>=0.7 - matrix_utils >=0.5 - - numpy >=1.23.5, <2 - - pint <=0.21 - - py7zr <=0.22.0 - - pyperclip - - pyprind - networkx + - numpy >=1.23.5, <3 - pandas >=2.2.1 + - py7zr >=0.22.0 + - pyperclip + - pyprind - pyside2 >=5.15.5 - qt-webengine - qtpy - - salib >=1.4, <1.5.1 + - salib >=1.4 - seaborn about: From 7d1228308ae54ad0e245e89bae6ad529ab2f17f6 Mon Sep 17 00:00:00 2001 From: Bernhard Steubing <33026150+bsteubing@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:33:28 -0500 Subject: [PATCH 265/267] Beta reduce pins (#1645) * Update dependency versions and replace np.NaN with np.nan for consistency * Seems we need numpy<2 since otherwise there are no wheels for the pip install --- recipe/meta.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/recipe/meta.yaml b/recipe/meta.yaml index c5e7567ee..0323c6f47 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -33,7 +33,7 @@ requirements: - loguru>=0.7 - matrix_utils >=0.5 - networkx - - numpy >=1.23.5, <3 + - numpy >=1.23.5, <2 - pandas >=2.2.1 - py7zr >=0.22.0 - pyperclip From a44086f22ce5a3d1e5586f2e0a311c972e107448 Mon Sep 17 00:00:00 2001 From: Bernhard Steubing <33026150+bsteubing@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:39:15 -0500 Subject: [PATCH 266/267] Revert commit to version 547cbd32442a485b5e6b87f1c0b49bb69427ffb5. Accidentally merged major into beta. (#1646) --- .github/workflows/README.md | 192 -- .github/workflows/build-executable.yml | 45 - .github/workflows/install-canary.yaml | 101 +- .github/workflows/testing.yaml | 4 +- activity_browser/README.md | 35 - activity_browser/__init__.py | 22 +- activity_browser/__main__.py | 109 +- activity_browser/app/README.md | 67 - activity_browser/app/__init__.py | 31 - activity_browser/app/actions/README.md | 92 - activity_browser/app/actions/__init__.py | 100 - .../app/actions/activity/activity_delete.py | 97 - .../actions/activity/activity_duplicate.py | 26 - .../activity/activity_duplicate_to_db.py | 158 -- .../app/actions/activity/activity_modify.py | 27 - .../actions/activity/activity_new_process.py | 130 - .../actions/activity/activity_new_product.py | 154 -- .../app/actions/activity/activity_open.py | 63 - .../app/actions/activity/activity_relink.py | 220 -- .../activity/activity_sdf_to_clipboard.py | 37 - .../activity/process_property_modify.py | 136 - .../activity/process_property_remove.py | 60 - activity_browser/app/actions/base.py | 61 - .../cs_add_functional_unit.py | 23 - .../cs_add_impact_category.py | 21 - .../actions/calculation_setup/cs_calculate.py | 76 - .../cs_change_functional_unit.py | 40 - .../actions/calculation_setup/cs_delete.py | 47 - .../cs_delete_functional_unit.py | 22 - .../cs_delete_impact_category.py | 22 - .../actions/calculation_setup/cs_duplicate.py | 49 - .../app/actions/calculation_setup/cs_new.py | 91 - .../app/actions/calculation_setup/cs_open.py | 25 - .../actions/calculation_setup/cs_rename.py | 49 - .../app/actions/database/database_delete.py | 96 - .../actions/database/database_duplicate.py | 102 - .../database/database_explorer_open.py | 21 - .../database/database_export_bw2package.py | 99 - .../actions/database/database_export_excel.py | 95 - .../database_import_from_ecoinvent.py | 477 ---- .../database/database_importer_bw2package.py | 90 - .../database/database_importer_excel.py | 211 -- .../app/actions/database/database_new.py | 111 - .../app/actions/database/database_open.py | 64 - .../app/actions/database/database_process.py | 19 - .../app/actions/database/database_relink.py | 273 -- .../actions/database/database_set_readonly.py | 33 - .../app/actions/exchange/exchange_copy_sdf.py | 23 - .../app/actions/exchange/exchange_delete.py | 19 - .../exchange/exchange_formula_remove.py | 27 - .../app/actions/exchange/exchange_modify.py | 57 - .../app/actions/exchange/exchange_new.py | 23 - .../exchange/exchange_sdf_to_clipboard.py | 34 - .../exchange/exchange_uncertainty_modify.py | 34 - .../exchange/exchange_uncertainty_remove.py | 23 - .../app/actions/metadatastore_cache_clear.py | 20 - .../app/actions/metadatastore_open.py | 21 - .../app/actions/method/cf_amount_modify.py | 29 - activity_browser/app/actions/method/cf_new.py | 46 - .../app/actions/method/cf_remove.py | 42 - .../actions/method/cf_uncertainty_modify.py | 47 - .../actions/method/cf_uncertainty_remove.py | 40 - .../method/importer/method_importer_bw2io.py | 59 - .../importer/method_importer_ecoinvent.py | 158 -- .../app/actions/method/method_delete.py | 93 - .../app/actions/method/method_duplicate.py | 149 -- .../app/actions/method/method_meta_modify.py | 25 - .../app/actions/method/method_new.py | 77 - .../app/actions/method/method_open.py | 28 - .../app/actions/method/method_rename.py | 112 - .../app/actions/migrations_install.py | 41 - .../app/actions/node_select_open.py | 29 - .../parameter/parameter_clear_broken.py | 38 - .../app/actions/parameter/parameter_delete.py | 67 - .../parameter/parameter_group_delete.py | 50 - .../app/actions/parameter/parameter_modify.py | 57 - .../app/actions/parameter/parameter_new.py | 208 -- .../parameter/parameter_new_automatic.py | 51 - .../parameter/parameter_new_from_parameter.py | 61 - .../app/actions/parameter/parameter_rename.py | 48 - .../parameter/parameter_uncertainty_modify.py | 36 - .../parameter/parameter_uncertainty_remove.py | 22 - .../project/project_create_template.py | 93 - .../app/actions/project/project_delete.py | 134 - .../app/actions/project/project_duplicate.py | 65 - .../app/actions/project/project_export.py | 81 - .../app/actions/project/project_import.py | 116 - .../actions/project/project_local_import.py | 278 -- .../actions/project/project_manager_open.py | 26 - .../app/actions/project/project_migrate25.py | 164 -- .../app/actions/project/project_new.py | 49 - .../app/actions/project/project_new_remote.py | 65 - .../actions/project/project_new_template.py | 87 - .../actions/project/project_remote_import.py | 318 --- .../app/actions/project/project_switch.py | 118 - .../app/actions/pyside_upgrade.py | 102 - .../app/actions/save_parameters_to_excel.py | 39 - .../tools/bw2io/tools_bw2io_migrations.py | 19 - activity_browser/app/dialogs/README.md | 13 - activity_browser/app/dialogs/__init__.py | 3 - .../app/dialogs/database_select_dialog.py | 35 - .../dialogs/import_preview_dialog/__init__.py | 1 - .../dialogs/import_preview_dialog/edge_tab.py | 253 -- .../import_preview_dialog.py | 30 - .../dialogs/import_preview_dialog/node_tab.py | 172 -- .../app/dialogs/node_select_dialog.py | 196 -- activity_browser/app/main.py | 282 -- activity_browser/app/menu_bar.py | 336 --- activity_browser/app/pages/README.md | 88 - activity_browser/app/pages/__init__.py | 14 - .../app/pages/activity_details/__init__.py | 1 - .../activity_details/activity_details.py | 169 -- .../pages/activity_details/activity_header.py | 310 --- .../pages/activity_details/consumers_tab.py | 163 -- .../app/pages/activity_details/data_tab.py | 189 -- .../pages/activity_details/description_tab.py | 51 - .../pages/activity_details/exchanges_tab.py | 834 ------ .../app/pages/activity_details/graph_tab.py | 333 --- .../pages/activity_details/parameters_tab.py | 387 --- .../app/pages/calculation_setup/__init__.py | 1 - .../calculation_setup/calculation_setup.py | 93 - .../functional_unit_section.py | 241 -- .../impact_category_section.py | 98 - .../calculation_setup/scenario_section.py | 580 ----- .../pages/impact_category_details/__init__.py | 1 - .../impact_category_details.py | 307 --- .../impact_category_header.py | 192 -- .../app/pages/lca_results/LCA_results.py | 2277 ----------------- .../app/pages/lca_results/__init__.py | 1 - .../app/pages/lca_results/dialogs.py | 574 ----- .../app/pages/lca_results/plots.py | 309 --- .../app/pages/lca_results/sankey_navigator.py | 473 ---- .../app/pages/lca_results/style.py | 25 - .../app/pages/lca_results/tables.py | 989 ------- .../app/pages/lca_results/tree_navigator.py | 506 ---- activity_browser/app/pages/metadatastore.py | 36 - .../app/pages/parameters/__init__.py | 2 - .../parameterized_exchanges_section.py | 296 --- .../app/pages/parameters/parameters.py | 65 - .../pages/parameters/parameters_section.py | 443 ---- activity_browser/app/pages/settings/README.md | 194 -- .../app/pages/settings/__init__.py | 18 - .../app/pages/settings/appearance.py | 98 - activity_browser/app/pages/settings/base.py | 39 - .../app/pages/settings/metadatastore.py | 125 - .../app/pages/settings/plugins.py | 166 -- .../app/pages/settings/project_manager.py | 227 -- .../app/pages/settings/settings_page.py | 159 -- .../app/pages/settings/startup.py | 218 -- activity_browser/app/pages/welcome.py | 75 - activity_browser/app/panes/README.md | 75 - activity_browser/app/panes/__init__.py | 10 - .../app/panes/calculation_setups.py | 202 -- .../app/panes/database_products.py | 608 ----- activity_browser/app/panes/databases.py | 327 --- .../app/panes/impact_categories.py | 178 -- activity_browser/app/signalling.py | 328 --- activity_browser/bwutils/README.md | 56 - activity_browser/bwutils/__init__.py | 15 + activity_browser/bwutils/commontasks.py | 97 +- .../ecospold2biosphereimporter.py | 27 +- activity_browser/bwutils/exporters.py | 3 +- activity_browser/bwutils/filesystem.py | 25 - activity_browser/bwutils/importers.py | 41 +- .../bwutils/io/ecoinvent_importer.py | 10 +- activity_browser/bwutils/metadata/README.md | 54 - activity_browser/bwutils/metadata/__init__.py | 4 +- activity_browser/bwutils/metadata/fields.py | 23 +- activity_browser/bwutils/metadata/loader.py | 299 +-- activity_browser/bwutils/metadata/metadata.py | 231 +- activity_browser/bwutils/metadata/searcher.py | 486 ---- activity_browser/bwutils/metadata/updater.py | 116 +- activity_browser/bwutils/montecarlo.py | 22 +- activity_browser/bwutils/multilca.py | 39 +- .../bwutils/searchengine/__init__.py | 2 - activity_browser/bwutils/searchengine/base.py | 779 ------ .../bwutils/searchengine/metadata_search.py | 447 ---- .../bwutils/sensitivity_analysis.py | 41 +- activity_browser/bwutils/settings.py | 110 - activity_browser/bwutils/strategies.py | 49 +- .../bwutils/superstructure/dataframe.py | 16 +- .../bwutils/superstructure/excel.py | 8 +- .../bwutils/superstructure/file_dialogs.py | 7 +- .../bwutils/superstructure/file_imports.py | 10 +- .../bwutils/superstructure/manager.py | 12 +- .../bwutils/superstructure/mlca.py | 4 +- .../bwutils/superstructure/utils.py | 6 +- activity_browser/bwutils/utils.py | 14 +- activity_browser/info.py | 61 +- activity_browser/mod/README.md | 58 - activity_browser/mod/bw2io/__init__.py | 20 +- activity_browser/mod/bw2io/ecoinvent.py | 20 +- .../bw2io/importers/ecospold2_biosphere.py | 21 +- activity_browser/static/README.md | 63 - activity_browser/static/css/README.md | 245 -- activity_browser/static/icons/README.md | 214 -- .../static/icons/exchanges/link.png | Bin 31393 -> 0 bytes .../static/icons/exchanges/relink.png | Bin 32995 -> 0 bytes .../static/icons/exchanges/unlink.png | Bin 34142 -> 0 bytes .../static/icons/main/activitybrowser.ico | Bin 124236 -> 0 bytes activity_browser/static/icons/main/star.png | Bin 1187 -> 0 bytes activity_browser/ui/README.md | 86 - activity_browser/ui/core/README.md | 178 -- activity_browser/ui/core/__init__.py | 1 - activity_browser/ui/core/application.py | 121 - activity_browser/ui/core/threading.py | 40 +- activity_browser/ui/core/tree_model.py | 585 ----- activity_browser/ui/delegates/README.md | 138 - activity_browser/ui/delegates/__init__.py | 6 +- activity_browser/ui/delegates/card.py | 192 -- activity_browser/ui/delegates/new_formula.py | 7 +- activity_browser/ui/delegates/string.py | 1 - activity_browser/ui/delegates/uncertainty.py | 54 +- activity_browser/ui/dialogs/README.md | 269 -- activity_browser/ui/dialogs/__init__.py | 4 - .../ui/dialogs/list_edit_dialog.py | 363 --- .../ui/dialogs/progress_dialog.py | 26 - .../ui/dialogs/uncertainty_dialog.py | 483 ---- activity_browser/ui/icons.py | 136 +- activity_browser/ui/widgets/README.md | 202 -- activity_browser/ui/widgets/__init__.py | 16 +- .../ui/widgets/abstract_navigator.py | 218 -- activity_browser/ui/widgets/abstract_page.py | 8 - activity_browser/ui/widgets/buttons.py | 63 - activity_browser/ui/widgets/central.py | 36 +- activity_browser/ui/widgets/cutoff_menu.py | 4 +- .../ui/widgets/database_name_edit.py | 3 +- activity_browser/ui/widgets/dock_widget.py | 87 +- activity_browser/ui/widgets/drop_overlay.py | 52 +- activity_browser/ui/widgets/formula_edit.py | 52 +- activity_browser/ui/widgets/line_edit.py | 22 +- activity_browser/ui/widgets/menu.py | 12 +- activity_browser/ui/widgets/plot.py | 64 - activity_browser/ui/widgets/tab_widget.py | 61 - activity_browser/ui/widgets/text_edit.py | 255 -- activity_browser/ui/widgets/tree_view.py | 259 -- .../ui/widgets/web_engine_page.py | 15 - activity_browser/ui/widgets/wizard.py | 91 +- activity_browser/ui/widgets/wizard_page.py | 8 +- docs/README.md | 205 -- docs/img.png | Bin 576 -> 0 bytes pyinstaller.spec | 74 - pyproject.toml | 13 +- recipe/README.md | 192 -- recipe/meta.yaml | 10 +- setup.py | 2 +- tests/README.md | 322 --- tests/actions/test_activity_actions.py | 20 +- .../actions/test_calculation_setup_actions.py | 12 +- tests/actions/test_database_actions.py | 36 +- tests/actions/test_exchange_actions.py | 53 +- tests/actions/test_method_actions.py | 32 +- tests/conftest.py | 59 +- tests/test_mds_cross_database.py | 119 - tests/test_search.py | 239 -- 255 files changed, 906 insertions(+), 30642 deletions(-) delete mode 100644 .github/workflows/README.md delete mode 100644 .github/workflows/build-executable.yml delete mode 100644 activity_browser/README.md delete mode 100644 activity_browser/app/README.md delete mode 100644 activity_browser/app/__init__.py delete mode 100644 activity_browser/app/actions/README.md delete mode 100644 activity_browser/app/actions/__init__.py delete mode 100644 activity_browser/app/actions/activity/activity_delete.py delete mode 100644 activity_browser/app/actions/activity/activity_duplicate.py delete mode 100644 activity_browser/app/actions/activity/activity_duplicate_to_db.py delete mode 100644 activity_browser/app/actions/activity/activity_modify.py delete mode 100644 activity_browser/app/actions/activity/activity_new_process.py delete mode 100644 activity_browser/app/actions/activity/activity_new_product.py delete mode 100644 activity_browser/app/actions/activity/activity_open.py delete mode 100644 activity_browser/app/actions/activity/activity_relink.py delete mode 100644 activity_browser/app/actions/activity/activity_sdf_to_clipboard.py delete mode 100644 activity_browser/app/actions/activity/process_property_modify.py delete mode 100644 activity_browser/app/actions/activity/process_property_remove.py delete mode 100644 activity_browser/app/actions/base.py delete mode 100644 activity_browser/app/actions/calculation_setup/cs_add_functional_unit.py delete mode 100644 activity_browser/app/actions/calculation_setup/cs_add_impact_category.py delete mode 100644 activity_browser/app/actions/calculation_setup/cs_calculate.py delete mode 100644 activity_browser/app/actions/calculation_setup/cs_change_functional_unit.py delete mode 100644 activity_browser/app/actions/calculation_setup/cs_delete.py delete mode 100644 activity_browser/app/actions/calculation_setup/cs_delete_functional_unit.py delete mode 100644 activity_browser/app/actions/calculation_setup/cs_delete_impact_category.py delete mode 100644 activity_browser/app/actions/calculation_setup/cs_duplicate.py delete mode 100644 activity_browser/app/actions/calculation_setup/cs_new.py delete mode 100644 activity_browser/app/actions/calculation_setup/cs_open.py delete mode 100644 activity_browser/app/actions/calculation_setup/cs_rename.py delete mode 100644 activity_browser/app/actions/database/database_delete.py delete mode 100644 activity_browser/app/actions/database/database_duplicate.py delete mode 100644 activity_browser/app/actions/database/database_explorer_open.py delete mode 100644 activity_browser/app/actions/database/database_export_bw2package.py delete mode 100644 activity_browser/app/actions/database/database_export_excel.py delete mode 100644 activity_browser/app/actions/database/database_import_from_ecoinvent.py delete mode 100644 activity_browser/app/actions/database/database_importer_bw2package.py delete mode 100644 activity_browser/app/actions/database/database_importer_excel.py delete mode 100644 activity_browser/app/actions/database/database_new.py delete mode 100644 activity_browser/app/actions/database/database_open.py delete mode 100644 activity_browser/app/actions/database/database_process.py delete mode 100644 activity_browser/app/actions/database/database_relink.py delete mode 100644 activity_browser/app/actions/database/database_set_readonly.py delete mode 100644 activity_browser/app/actions/exchange/exchange_copy_sdf.py delete mode 100644 activity_browser/app/actions/exchange/exchange_delete.py delete mode 100644 activity_browser/app/actions/exchange/exchange_formula_remove.py delete mode 100644 activity_browser/app/actions/exchange/exchange_modify.py delete mode 100644 activity_browser/app/actions/exchange/exchange_new.py delete mode 100644 activity_browser/app/actions/exchange/exchange_sdf_to_clipboard.py delete mode 100644 activity_browser/app/actions/exchange/exchange_uncertainty_modify.py delete mode 100644 activity_browser/app/actions/exchange/exchange_uncertainty_remove.py delete mode 100644 activity_browser/app/actions/metadatastore_cache_clear.py delete mode 100644 activity_browser/app/actions/metadatastore_open.py delete mode 100644 activity_browser/app/actions/method/cf_amount_modify.py delete mode 100644 activity_browser/app/actions/method/cf_new.py delete mode 100644 activity_browser/app/actions/method/cf_remove.py delete mode 100644 activity_browser/app/actions/method/cf_uncertainty_modify.py delete mode 100644 activity_browser/app/actions/method/cf_uncertainty_remove.py delete mode 100644 activity_browser/app/actions/method/importer/method_importer_bw2io.py delete mode 100644 activity_browser/app/actions/method/importer/method_importer_ecoinvent.py delete mode 100644 activity_browser/app/actions/method/method_delete.py delete mode 100644 activity_browser/app/actions/method/method_duplicate.py delete mode 100644 activity_browser/app/actions/method/method_meta_modify.py delete mode 100644 activity_browser/app/actions/method/method_new.py delete mode 100644 activity_browser/app/actions/method/method_open.py delete mode 100644 activity_browser/app/actions/method/method_rename.py delete mode 100644 activity_browser/app/actions/migrations_install.py delete mode 100644 activity_browser/app/actions/node_select_open.py delete mode 100644 activity_browser/app/actions/parameter/parameter_clear_broken.py delete mode 100644 activity_browser/app/actions/parameter/parameter_delete.py delete mode 100644 activity_browser/app/actions/parameter/parameter_group_delete.py delete mode 100644 activity_browser/app/actions/parameter/parameter_modify.py delete mode 100644 activity_browser/app/actions/parameter/parameter_new.py delete mode 100644 activity_browser/app/actions/parameter/parameter_new_automatic.py delete mode 100644 activity_browser/app/actions/parameter/parameter_new_from_parameter.py delete mode 100644 activity_browser/app/actions/parameter/parameter_rename.py delete mode 100644 activity_browser/app/actions/parameter/parameter_uncertainty_modify.py delete mode 100644 activity_browser/app/actions/parameter/parameter_uncertainty_remove.py delete mode 100644 activity_browser/app/actions/project/project_create_template.py delete mode 100644 activity_browser/app/actions/project/project_delete.py delete mode 100644 activity_browser/app/actions/project/project_duplicate.py delete mode 100644 activity_browser/app/actions/project/project_export.py delete mode 100644 activity_browser/app/actions/project/project_import.py delete mode 100644 activity_browser/app/actions/project/project_local_import.py delete mode 100644 activity_browser/app/actions/project/project_manager_open.py delete mode 100644 activity_browser/app/actions/project/project_migrate25.py delete mode 100644 activity_browser/app/actions/project/project_new.py delete mode 100644 activity_browser/app/actions/project/project_new_remote.py delete mode 100644 activity_browser/app/actions/project/project_new_template.py delete mode 100644 activity_browser/app/actions/project/project_remote_import.py delete mode 100644 activity_browser/app/actions/project/project_switch.py delete mode 100644 activity_browser/app/actions/pyside_upgrade.py delete mode 100644 activity_browser/app/actions/save_parameters_to_excel.py delete mode 100644 activity_browser/app/actions/tools/bw2io/tools_bw2io_migrations.py delete mode 100644 activity_browser/app/dialogs/README.md delete mode 100644 activity_browser/app/dialogs/__init__.py delete mode 100644 activity_browser/app/dialogs/database_select_dialog.py delete mode 100644 activity_browser/app/dialogs/import_preview_dialog/__init__.py delete mode 100644 activity_browser/app/dialogs/import_preview_dialog/edge_tab.py delete mode 100644 activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py delete mode 100644 activity_browser/app/dialogs/import_preview_dialog/node_tab.py delete mode 100644 activity_browser/app/dialogs/node_select_dialog.py delete mode 100644 activity_browser/app/main.py delete mode 100644 activity_browser/app/menu_bar.py delete mode 100644 activity_browser/app/pages/README.md delete mode 100644 activity_browser/app/pages/__init__.py delete mode 100644 activity_browser/app/pages/activity_details/__init__.py delete mode 100644 activity_browser/app/pages/activity_details/activity_details.py delete mode 100644 activity_browser/app/pages/activity_details/activity_header.py delete mode 100644 activity_browser/app/pages/activity_details/consumers_tab.py delete mode 100644 activity_browser/app/pages/activity_details/data_tab.py delete mode 100644 activity_browser/app/pages/activity_details/description_tab.py delete mode 100644 activity_browser/app/pages/activity_details/exchanges_tab.py delete mode 100644 activity_browser/app/pages/activity_details/graph_tab.py delete mode 100644 activity_browser/app/pages/activity_details/parameters_tab.py delete mode 100644 activity_browser/app/pages/calculation_setup/__init__.py delete mode 100644 activity_browser/app/pages/calculation_setup/calculation_setup.py delete mode 100644 activity_browser/app/pages/calculation_setup/functional_unit_section.py delete mode 100644 activity_browser/app/pages/calculation_setup/impact_category_section.py delete mode 100644 activity_browser/app/pages/calculation_setup/scenario_section.py delete mode 100644 activity_browser/app/pages/impact_category_details/__init__.py delete mode 100644 activity_browser/app/pages/impact_category_details/impact_category_details.py delete mode 100644 activity_browser/app/pages/impact_category_details/impact_category_header.py delete mode 100644 activity_browser/app/pages/lca_results/LCA_results.py delete mode 100644 activity_browser/app/pages/lca_results/__init__.py delete mode 100644 activity_browser/app/pages/lca_results/dialogs.py delete mode 100644 activity_browser/app/pages/lca_results/plots.py delete mode 100644 activity_browser/app/pages/lca_results/sankey_navigator.py delete mode 100644 activity_browser/app/pages/lca_results/style.py delete mode 100644 activity_browser/app/pages/lca_results/tables.py delete mode 100644 activity_browser/app/pages/lca_results/tree_navigator.py delete mode 100644 activity_browser/app/pages/metadatastore.py delete mode 100644 activity_browser/app/pages/parameters/__init__.py delete mode 100644 activity_browser/app/pages/parameters/parameterized_exchanges_section.py delete mode 100644 activity_browser/app/pages/parameters/parameters.py delete mode 100644 activity_browser/app/pages/parameters/parameters_section.py delete mode 100644 activity_browser/app/pages/settings/README.md delete mode 100644 activity_browser/app/pages/settings/__init__.py delete mode 100644 activity_browser/app/pages/settings/appearance.py delete mode 100644 activity_browser/app/pages/settings/base.py delete mode 100644 activity_browser/app/pages/settings/metadatastore.py delete mode 100644 activity_browser/app/pages/settings/plugins.py delete mode 100644 activity_browser/app/pages/settings/project_manager.py delete mode 100644 activity_browser/app/pages/settings/settings_page.py delete mode 100644 activity_browser/app/pages/settings/startup.py delete mode 100644 activity_browser/app/pages/welcome.py delete mode 100644 activity_browser/app/panes/README.md delete mode 100644 activity_browser/app/panes/__init__.py delete mode 100644 activity_browser/app/panes/calculation_setups.py delete mode 100644 activity_browser/app/panes/database_products.py delete mode 100644 activity_browser/app/panes/databases.py delete mode 100644 activity_browser/app/panes/impact_categories.py delete mode 100644 activity_browser/app/signalling.py delete mode 100644 activity_browser/bwutils/README.md delete mode 100644 activity_browser/bwutils/filesystem.py delete mode 100644 activity_browser/bwutils/metadata/README.md delete mode 100644 activity_browser/bwutils/metadata/searcher.py delete mode 100644 activity_browser/bwutils/searchengine/__init__.py delete mode 100644 activity_browser/bwutils/searchengine/base.py delete mode 100644 activity_browser/bwutils/searchengine/metadata_search.py delete mode 100644 activity_browser/bwutils/settings.py delete mode 100644 activity_browser/mod/README.md delete mode 100644 activity_browser/static/README.md delete mode 100644 activity_browser/static/css/README.md delete mode 100644 activity_browser/static/icons/README.md delete mode 100644 activity_browser/static/icons/exchanges/link.png delete mode 100644 activity_browser/static/icons/exchanges/relink.png delete mode 100644 activity_browser/static/icons/exchanges/unlink.png delete mode 100644 activity_browser/static/icons/main/activitybrowser.ico delete mode 100644 activity_browser/static/icons/main/star.png delete mode 100644 activity_browser/ui/README.md delete mode 100644 activity_browser/ui/core/README.md delete mode 100644 activity_browser/ui/core/application.py delete mode 100644 activity_browser/ui/core/tree_model.py delete mode 100644 activity_browser/ui/delegates/README.md delete mode 100644 activity_browser/ui/delegates/card.py delete mode 100644 activity_browser/ui/dialogs/README.md delete mode 100644 activity_browser/ui/dialogs/__init__.py delete mode 100644 activity_browser/ui/dialogs/list_edit_dialog.py delete mode 100644 activity_browser/ui/dialogs/progress_dialog.py delete mode 100644 activity_browser/ui/dialogs/uncertainty_dialog.py delete mode 100644 activity_browser/ui/widgets/README.md delete mode 100644 activity_browser/ui/widgets/abstract_navigator.py delete mode 100644 activity_browser/ui/widgets/abstract_page.py delete mode 100644 activity_browser/ui/widgets/buttons.py delete mode 100644 activity_browser/ui/widgets/plot.py delete mode 100644 activity_browser/ui/widgets/tab_widget.py delete mode 100644 activity_browser/ui/widgets/text_edit.py delete mode 100644 activity_browser/ui/widgets/tree_view.py delete mode 100644 activity_browser/ui/widgets/web_engine_page.py delete mode 100644 docs/README.md delete mode 100644 docs/img.png delete mode 100644 pyinstaller.spec delete mode 100644 recipe/README.md delete mode 100644 tests/README.md delete mode 100644 tests/test_mds_cross_database.py delete mode 100644 tests/test_search.py diff --git a/.github/workflows/README.md b/.github/workflows/README.md deleted file mode 100644 index 407cee17a..000000000 --- a/.github/workflows/README.md +++ /dev/null @@ -1,192 +0,0 @@ -# GitHub Actions Workflows - -This document describes the GitHub Actions workflows used in the Activity Browser project. - -## Overview - -The Activity Browser project uses five GitHub Actions workflows to automate testing, deployment, and project management tasks: - -1. **Automated Testing** - Runs tests on every push and PR -2. **Canary Installation** - Daily installation checks to catch dependency issues -3. **Beta Deployment** - Publishes beta releases to PyPI and Anaconda -4. **Stable Release** - Creates releases and publishes to Anaconda -5. **Milestone Comments** - Automatically notifies users when issues are resolved in releases - ---- - -## 1. Automated Testing (`testing.yaml`) - -**Trigger:** Push or pull request to the `major` branch - -**Purpose:** Ensures code quality by running the test suite across multiple operating systems and Python versions. - -### Matrix Strategy -- **Operating Systems:** Ubuntu (latest), Windows (latest), macOS 15, macOS (latest) -- **Python Versions:** 3.10, 3.11, 3.12 -- **Total combinations:** 12 test runs per trigger - -### Steps -1. Checkout code -2. Set up Python for the specified version -3. Install Qt libraries (Linux only) -4. Update pip, setuptools, and wheel -5. Install package with testing dependencies: `pip install .[testing]` -6. Run pytest with minimal output: `pytest -s --no-header --no-summary -q` - -### Environment -- Sets `QT_QPA_PLATFORM=offscreen` for headless GUI testing -- Uses `fail-fast: false` to run all combinations even if some fail - ---- - -## 2. Canary Installation (`install-canary.yaml`) - -**Trigger:** Scheduled daily at 7:00 AM UTC (cron: `0 7 * * *`) - -**Purpose:** Proactively detects dependency issues by performing fresh installations of Activity Browser from PyPI daily. - -### Matrix Strategy -- **Operating Systems:** Ubuntu (latest), Windows (latest), macOS 15, macOS (latest) -- **Python Versions:** 3.10, 3.11, 3.12 -- **Timeout:** 12 minutes per job - -### Steps -1. Checkout code -2. Set up Python -3. Install activity-browser from PyPI (not from source) -4. Generate environment info with `pip freeze` -5. Upload frozen requirements as artifact for each OS/Python combination - -### Notes -- Uses `bash -e {0}` shell to exit on error -- Helps catch breaking changes in dependencies before users encounter them -- Artifacts show exact dependency versions that successfully installed - ---- - -## 3. Beta Deployment (`python-package-deploy.yml`) - -**Trigger:** Push to `beta` branch or any tag - -**Purpose:** Publishes beta versions to PyPI (test and production) and Anaconda Cloud. - -### Version Scheme -- Beta version format: `3.0.0b` where N is the commit count since commit `199b6c3` -- Calculated dynamically: `git rev-list 199b6c3..HEAD --count` - -### Steps -1. Checkout with full git history (`fetch-depth: "0"`) -2. Calculate and set version number -3. Set up Python 3.11 -4. Install `build` package -5. Build wheel and source distribution -6. **PyPI Publishing:** - - Publish to Test PyPI (with `skip-existing: true`) - - Publish to production PyPI -7. **Conda Publishing:** - - Set up Conda environment from `.github/conda-envs/build.yml` - - Build Conda package: `conda build -c conda-forge -c cmutel ./recipe/` - - Upload to Anaconda Cloud using `CONDA_LCA` secret token - -### Permissions -- Requires `id-token: write` for PyPI trusted publishing - ---- - -## 4. Stable Release (`release.yaml`) - -**Trigger:** Push of any git tag - -**Purpose:** Creates GitHub releases with auto-generated changelogs and publishes stable versions to Anaconda. - -### Steps -1. Checkout code -2. **Generate Changelog:** - - Uses `mikepenz/release-changelog-builder-action@v4` - - Configuration from `.github/changelog-configuration.json` - - Builds changelog from PRs with labels -3. **Create GitHub Release:** - - Uses `ncipollo/release-action@v1` - - Includes generated changelog as release notes - - Targets `main` branch commit -4. **Build and Upload Conda Package:** - - Set up Conda environment (Python 3.11) - - Build with `conda build recipe/` - - Upload to Anaconda using `CONDA_UPLOAD_TOKEN` secret -5. **Update Wiki:** - - Runs `.github/scripts/update_wiki.sh` to automatically update documentation - -### Notes -- Only runs on tagged commits (version releases) -- Creates public GitHub releases visible to users -- Updates project wiki documentation automatically - ---- - -## 5. Milestone Comments (`comment-milestoned-issues.yaml`) - -**Trigger:** When a milestone is closed - -**Purpose:** Automatically notifies users on closed issues when their issue has been implemented in a release. - -### Steps -1. Uses `actions/github-script@v5` to run JavaScript automation -2. Gets milestone number and title from the event -3. Lists all issues associated with the milestone -4. For each closed issue (not PRs): - - Posts a comment with: - - Link to the new release - - Instructions to update Activity Browser - - Link to subscribe to the updates mailing list - - Bot disclaimer - -### Comment Template -The bot posts a formatted note: -- Informs that the issue is implemented in version X -- Provides update instructions -- Offers subscription to updates mailing list (brightway.groups.io) -- Includes bot identification - ---- - -## Workflow Dependencies - -### Secrets Required -- `GITHUB_TOKEN` - Automatically provided by GitHub Actions -- `CONDA_LCA` - Anaconda upload token for beta releases -- `CONDA_UPLOAD_TOKEN` - Anaconda upload token for stable releases - -### Configuration Files -- `.github/conda-envs/build.yml` - Conda environment for building packages -- `.github/changelog-configuration.json` - Changelog generation configuration -- `.github/scripts/update_wiki.sh` - Wiki update script -- `recipe/meta.yaml` - Conda package recipe -- `pyproject.toml` - Python package configuration - ---- - -## Development Notes - -### Running Tests Locally -To run the same tests that CI runs: -```bash -pip install .[testing] -pytest -s --no-header --no-summary -q -``` - -### Testing Matrix Changes -When modifying the test matrix (OS or Python versions): -- Update both `testing.yaml` and `install-canary.yaml` to keep them in sync -- Consider the maintenance burden of additional combinations -- Current support: Python 3.10-3.12, Ubuntu/Windows/macOS - -### Release Process -1. **Beta release:** Push to `beta` branch → Auto-publishes beta version -2. **Stable release:** Create and push a tag → Creates GitHub release and publishes to Anaconda -3. **Close milestone:** When closing a milestone → Users get notified automatically - -### Monitoring -- Check daily canary runs to catch dependency issues -- Review failed test runs in PR checks before merging -- Monitor PyPI and Anaconda Cloud for successful uploads - diff --git a/.github/workflows/build-executable.yml b/.github/workflows/build-executable.yml deleted file mode 100644 index 688a9011a..000000000 --- a/.github/workflows/build-executable.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Build Executable -on: - push: - branches: [ major ] - tags: '*' - -jobs: - build: - strategy: - fail-fast: false - matrix: - os: [windows-latest, ubuntu-latest, macos-latest, macos-15] - runs-on: ${{ matrix.os }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Install system dependencies (Linux) - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y libegl1 libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.12 - - - name: Install UV - uses: astral-sh/setup-uv@v2 - - - name: Sync dependencies - shell: bash - run: | - uv add pyinstaller - uv sync --prerelease=allow - - - name: Build executable with PyInstaller - shell: bash - run: | - uv run pyinstaller pyinstaller.spec - - - uses: actions/upload-artifact@v4 - with: - name: activity-browser-${{ matrix.os }} - path: dist/* \ No newline at end of file diff --git a/.github/workflows/install-canary.yaml b/.github/workflows/install-canary.yaml index fccc5d456..066bb8958 100644 --- a/.github/workflows/install-canary.yaml +++ b/.github/workflows/install-canary.yaml @@ -3,16 +3,113 @@ on: schedule: # Run the tests once every 24 hours to catch dependency problems early - cron: '0 7 * * *' + push: + branches: + - install-canary jobs: + canary-installs: + timeout-minutes: 12 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-13] + python-version: ["3.10", "3.11"] + defaults: + run: + shell: bash -l {0} + steps: + - name: Setup python ${{ matrix.python-version }} conda environment + uses: conda-incubator/setup-miniconda@v3 + with: + python-version: ${{ matrix.python-version }} + miniconda-version: "latest" + - name: Install activity-browser + run: | + conda create -y -n ab -c conda-forge --solver libmamba activity-browser python=${{ matrix.python-version }} + - name: Environment info + run: | + conda activate ab + conda list + conda env export + conda env export -f env.yaml + - name: Upload final environment as artifact + uses: actions/upload-artifact@v4 + with: + name: env-${{ matrix.os }}-${{ matrix.python-version }} + path: env.yaml + + # also run install with micromamba instead of conda to have a timing comparison + canary-installs-mamba: + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ['3.11'] + defaults: + run: + shell: bash -l {0} + steps: + - name: Setup python ${{ matrix.python-version }} conda environment + uses: mamba-org/setup-micromamba@v1 + with: + micromamba-version: '1.5.9-1' + environment-name: ab + create-args: >- + python=${{ matrix.python-version }} + activity-browser + - name: Environment info + run: | + micromamba list + micromamba env export + micromamba env export > env.yaml + - name: Upload final environment as artifact + uses: actions/upload-artifact@v4 + with: + name: env-${{ matrix.os }}-${{ matrix.python-version }}-mamba + path: env.yaml + + conda-micromamba-comparison: + runs-on: ubuntu-latest + defaults: + run: + shell: bash -l {0} + needs: + - canary-installs + - canary-installs-mamba + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + - name: show files + run: | + ls -la + - name: correct yaml formatting + # add correct indentation to make diffing possible + uses: mikefarah/yq@master + with: + cmd: | + ls | grep mamba | while read d; do yq -i $d/env.yaml; done + - name: diff ubuntu + run: | + diff -u env-ubuntu-latest-3.11* || : + - name: diff windows + run: | + diff -u env-windows-latest-3.11* || : + - name: diff macos + run: | + diff -u env-macos-latest-3.11* || : + canary-installs-pip: timeout-minutes: 12 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-15, macos-latest] - py-version: ["3.10", "3.11", "3.12"] + os: [ ubuntu-latest, windows-latest, macos-13 ] + python-version: [ '3.10' ] defaults: run: shell: bash -e {0} diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index a0d5bee6e..ec25e782e 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-15, macos-latest] + os: [ubuntu-latest, windows-latest, macos-13, macos-latest] py-version: ["3.10", "3.11", "3.12"] env: QT_QPA_PLATFORM: 'offscreen' @@ -42,4 +42,4 @@ jobs: - name: Test with pytest run: | - pytest -s --no-header --no-summary -q + pytest diff --git a/activity_browser/README.md b/activity_browser/README.md deleted file mode 100644 index 6339d5cd5..000000000 --- a/activity_browser/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# activity_browser - -This is the main package directory for the Activity Browser application. - -## Overview - -Activity Browser is a Qt-based desktop application that provides a GUI front-end for Brightway2, enabling users to perform Life Cycle Assessment (LCA) calculations with an intuitive interface. - -## Directory Structure - -- **`app/`** - Main application logic, including the main window, actions, dialogs, pages, and panes -- **`bwutils/`** - Utility functions and helpers that extend Brightway2 functionality -- **`mod/`** - Monkey-patches and modifications to third-party libraries (bw2analyzer, bw2io, etc.) -- **`static/`** - Static resources including HTML templates, CSS, icons, fonts, and JavaScript files -- **`ui/`** - Core UI components including widgets, dialogs, wizards, and web views - -## Key Files - -- **`__init__.py`** - Package initialization with PySide6/typing compatibility patches -- **`__main__.py`** - Entry point for the application (`run_activity_browser` function) -- **`info.py`** - Version and application metadata - -## Entry Points - -The application can be started in multiple ways: -- Console script: `activity-browser` (installed via setuptools) -- Direct module execution: `python -m activity_browser` -- Script execution: `python run-activity-browser.py` - -All entry points lead to `activity_browser.__main__:run_activity_browser`. - -## Development Notes - -- See `CONTRIBUTING.md` for guidelines on contributing to the project -- Check out the Development notes specific to each submodule for more details on implementation diff --git a/activity_browser/__init__.py b/activity_browser/__init__.py index e04ee0533..1e67a74f5 100644 --- a/activity_browser/__init__.py +++ b/activity_browser/__init__.py @@ -14,26 +14,8 @@ except ImportError: import qtpy -def setup_logging(): - """Configure loguru sinks for console and file logging.""" - from loguru import logger - import os - import platformdirs - - logger.level("SYNC", no=9, color="") - logger.level("SIGNAL", no=19, color="") - logger.level("TEST", no=19, color="") - - logger.remove() - logger.add(sys.stderr, level=6, colorize=True, - format="{time:HH:mm:ss} | {level: <8} | {message}") - - log_dir = platformdirs.user_log_dir(appname="ActivityBrowser", appauthor="pylca") - os.makedirs(log_dir, exist_ok=True) - log_file = os.path.join(log_dir, "activity_browser.log") - logger.add(log_file, level="DEBUG", rotation="5 MB", retention=5) +from .ui.application import application +from .signals import signals def run_activity_browser(): from .__main__ import run_activity_browser - -setup_logging() \ No newline at end of file diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index 0e7073f61..418718791 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -1,11 +1,6 @@ -# Divert the program flow in worker sub-process as soon as possible, -# before importing heavy-weight modules. -if __name__ == '__main__': - import multiprocessing - multiprocessing.freeze_support() - import sys import os +from logging import getLogger from importlib import metadata import requests @@ -18,11 +13,13 @@ import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("activity.browser.1") -from loguru import logger -import platformdirs -from .static.icons import main +from activity_browser import application +from activity_browser.ui import icons +from .logger import setup_ab_logging +from .static.icons import main +log = getLogger(__name__) class SpecialProgressBar(QtWidgets.QWidget): @@ -91,14 +88,29 @@ def load_modules(self): thread.start() def load_layout(self): - self.load_finished() + from .ui.widgets import MainWindow, CentralTabWidget + from .layouts import panes, pages + from activity_browser.bwutils import AB_metadata + from activity_browser import signals - def load_finished(self): - from activity_browser import app + application.main_window = MainWindow() + central_widget = CentralTabWidget(application.main_window) + central_widget.addTab(pages.WelcomePage(), "Welcome") + central_widget.addTab(pages.ParametersPage(), "Parameters") + + application.main_window.setCentralWidget(central_widget) - load_plugins() + self.load_settings() - app.main_window.show() + def load_settings(self): + self.text_label.setText("Loading project") + thread = SettingsThread(self) + thread.finished.connect(self.load_finished) + thread.start() + + def load_finished(self): + application.main_window.sync() + application.main_window.show() self.deleteLater() @@ -107,36 +119,70 @@ class ModuleThread(QtCore.QThread): def run(self): self.status.emit("Loading Numpy") - logger.debug("ABLoader: Importing numpy") + log.debug("ABLoader: Importing numpy") import numpy, pandas self.status.emit("Loading Brightway25") - logger.debug("ABLoader: Importing brightway modules") + log.debug("ABLoader: Importing brightway modules") import bw2data, bw2calc, bw2analyzer, bw2io, bw_functional, bw_processing, matrix_utils + self.status.emit("Loading Activity Browser") + log.debug("ABLoader: Importing activity_browser") + from activity_browser import actions, layouts, mod, settings, ui, signals + from activity_browser.layouts import panes, pages + from activity_browser.ui import core, widgets, web, wizards -def run_activity_browser(): - from activity_browser.ui.core.application import ABApplication - app = ABApplication() +class SettingsThread(QtCore.QThread): + def run(self): + import bw2data as bd + from activity_browser import settings, actions + + if settings.ab_settings.settings: + from pathlib import Path + base_dir = Path(settings.ab_settings.current_bw_dir) + project_name = settings.ab_settings.startup_project + bd.projects.change_base_directories(base_dir, project_name=project_name, update=False) + + if not bd.projects.twofive: + log.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") + actions.ProjectSwitch.set_warning_bar() + + log.info(f"Brightway2 data directory: {bd.projects._base_data_dir}") + log.info(f"Brightway2 current project: {bd.projects.current}") + + +def run_activity_browser(): pre_flight_checks() + setup_ab_logging() loader = ABLoader() loader.show() - - app.set_icon() # setting this here seems to fix the icon not showing sometimes - sys.exit(app.exec_()) + application.set_icon() # setting this here seems to fix the icon not showing sometimes + sys.exit(application.exec_()) def run_activity_browser_no_launcher(): pre_flight_checks() + setup_ab_logging() modules = ModuleThread() modules.run() - from .ui.widgets import CentralTabWidget - from .app import panes, pages, application, metadata + from .ui.widgets import MainWindow, CentralTabWidget + from .layouts import panes, pages + from activity_browser.bwutils import AB_metadata + from activity_browser import signals + + application.main_window = MainWindow() + central_widget = CentralTabWidget(application.main_window) + central_widget.addTab(pages.WelcomePage(), "Welcome") + central_widget.addTab(pages.ParametersPage(), "Parameters") - load_plugins() + application.main_window.setCentralWidget(central_widget) + settings = SettingsThread() + settings.run() + + application.main_window.sync() application.main_window.show() application.set_icon() # setting this here seems to fix the icon not showing sometimes @@ -202,22 +248,11 @@ def check_pypi_update(): "pip install --upgrade activity-browser\n\n" "Press any key to continue without updating...\033[0m") -def load_plugins(): - from activity_browser.bwutils.settings import Settings - settings = Settings() - plugins = settings["plugins"].get("enabled_plugins", []) - for plugin in plugins: - try: - __import__(plugin) - logger.info(f"Successfully loaded plugin: {plugin}") - except ImportError: - logger.warning(f"Could not load plugin: {plugin}") - if "--no-launcher" in sys.argv: run_activity_browser_no_launcher() elif sys.version_info[1] == 10: - logger.info("Running Activity Browser without launcher for Python 3.10") + log.info("Running Activity Browser without launcher for Python 3.10") run_activity_browser_no_launcher() else: run_activity_browser() diff --git a/activity_browser/app/README.md b/activity_browser/app/README.md deleted file mode 100644 index 7e24d9129..000000000 --- a/activity_browser/app/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# app - -Main application module containing the core logic and structure of the Activity Browser. - -## Overview - -This module orchestrates the main application components including the main window, menu bar, signal handling, and various UI elements organized into actions, dialogs, pages, and panes. - -## Directory Structure - -- **`actions/`** - Encapsulated UI operations and commands (activity, database, calculation setup, etc.) -- **`dialogs/`** - Dialog windows for user interactions -- **`pages/`** - Main content pages displayed in the application (activity details, calculations, parameters, etc.) -- **`panes/`** - Dock-able panes that can be arranged around the main content area - -## Key Files - -- **`__init__.py`** - Module initialization creating singleton instances: - - `application` - ABApplication instance - - `metadata` - MetaDataStore instance - - `settings` - Settings instance - - `signals` - ABSignals instance (event bus) - - `main_window` - MainWindow instance - -- **`main_window.py`** - MainWindow class that holds the central widget and dock panes -- **`menu_bar.py`** - Application menu bar with File, Edit, View, Tools, Help menus -- **`signalling.py`** - ABSignals class that bridges bw2data signals to Qt signals - -## Architecture - -The app module creates and wires together the core application components: - -1. **Application** (`ABApplication`) - Qt application instance with global shortcut management -2. **Signals** (`ABSignals`) - Project-wide event bus for model to UI communication -3. **Main Window** (`MainWindow`) - Main application window with pages and panes -4. **Actions** - Command pattern implementation for menu items and toolbar actions. Modifying Brightway2 happens here. -5. **Pages** - Content area widgets for different application views -6. **Panes** - Dock-able side panels - -## Signal Flow - -The signals instance serves as the central event bus: -- Bridges Brightway2 data events to Qt signals -- Enables loose coupling between UI components -- Used throughout the application for state updates - -## Usage Pattern - -Components should access the application objects via: - -```python -from activity_browser import app - -# Access global instances -app.application # ABApplication instance -app.signals # Event bus -app.settings # Settings manager -app.metadata # Metadata store -app.main_window # Main window -``` - -## Development Notes - -- See `CONTRIBUTING.md` for guidelines on contributing to the project -- This module is the place to add components that depend on the application having been initialized (e.g., actions, panes) - - If the logic you want to add can only depend on brightway2, consider placing it in the `bwutils` submodule instead - - If the widget you want to add does not depend on the application, consider placing it in the `ui` submodule instead diff --git a/activity_browser/app/__init__.py b/activity_browser/app/__init__.py deleted file mode 100644 index 275cdb6b2..000000000 --- a/activity_browser/app/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -__all__ = ["panes", "pages", "application", "signals", "metadata", "main_window", "actions"] - -import os - -from activity_browser.ui.core.application import ABApplication -from activity_browser.bwutils.metadata import MetaDataStore -from activity_browser.bwutils.settings import Settings -from .main import MainWindow - -application = ABApplication() -metadata = MetaDataStore(application) -settings = Settings() - -# modules dependent on application instance -from .signalling import ABSignals - -signals = ABSignals() - -# modules dependent on application and signals -from . import actions -from . import panes -from . import pages -from . import dialogs - -main_window = MainWindow() -application.main_window = main_window - -if not os.environ.get("AB_SKIP_SETTINGS_ON_STARTUP"): - main_window.apply_settings(load=True) # Ensure settings are applied at startup - diff --git a/activity_browser/app/actions/README.md b/activity_browser/app/actions/README.md deleted file mode 100644 index 028d435c3..000000000 --- a/activity_browser/app/actions/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# actions - -Encapsulated UI operations and commands following the action pattern. - -## Overview - -This directory contains all user-triggered actions in Activity Browser. Each action represents a discrete operation that can be invoked from menus, toolbars, or keyboard shortcuts. - -## Directory Structure - -- **`activity/`** - Actions related to activities (create, edit, delete, duplicate, etc.) -- **`calculation_setup/`** - Actions for calculation setup management -- **`database/`** - Database operations (import, export, delete, backup, etc.) -- **`exchange/`** - Actions for exchanges between activities -- **`method/`** - Impact assessment method management -- **`parameter/`** - Parameter management actions -- **`project/`** - Project-level operations -- **`tools/`** - Various tools and utilities accessible via actions - -## Action Pattern - -All actions follow a consistent pattern defined in `base.py`: - -```python -class MyAction(ABAction): - icon = QtGui.QIcon(...) # Action icon - text = "My Action" # Display text - tooltip = "Description" # Tooltip text - - @staticmethod - def run(*args, **kwargs): - # Action implementation - pass -``` - -### Key Features: - -1. **Declarative** - Icon, text, and tooltip defined as class attributes -2. **Callable arguments** - Arguments can be functions (evaluated at runtime) -3. **Qt integration** - Can be converted to QAction or QPushButton -4. **Exception handling** - Always add `@exception_dialogs` decorator for user-facing errors -5. **Flexible invocation** - Triggered from menus, buttons, shortcuts - -## Usage - -Actions can be used in multiple ways: - -### As Menu Items -```python -action = MyAction.get_QAction(parent=menu) -menu.addAction(action) -``` - -### As Buttons -```python -button = MyAction.get_QButton() -layout.addWidget(button) -``` - -### Direct Invocation -```python -MyAction.run(arg1, arg2) -``` - -## Subdirectory Organization - -Each subdirectory groups related actions: - -- **`activity/`** - Activity CRUD operations, navigation, graph viewing -- **`calculation_setup/`** - Setup creation, modification, calculation execution -- **`database/`** - Import from various sources, export, deletion, backup/restore -- **`exchange/`** - Add/remove/modify exchanges, uncertainty, formulas -- **`method/`** - Method import, export, modification, deletion -- **`parameter/`** - Parameter creation, editing, scenarios -- **`project/`** - Project creation, switching, deletion, settings -- **`tools/`** - Monte Carlo, sensitivity analysis, superstructure tools - -## Development Guidelines - -When adding new actions: - -1. Inherit from `ABAction` base class -2. Define icon, text, and tooltip class attributes -3. Implement the `run()` static method with the action logic -4. Place in the appropriate subdirectory by functionality -5. Use `@exception_dialogs` decorator for user-facing error handling -6. Import and register in the parent `__init__.py` -7. Connect to global signals when state changes - -## Signal Integration - -**Actions should not emit signals themselves** That being said, actions should only emit signals when they modify state in a way that Brightway2 does not automatically notify the UI about. See e.g. parameter_group_delete.py for an example of emitting a signal after deleting a parameter group. diff --git a/activity_browser/app/actions/__init__.py b/activity_browser/app/actions/__init__.py deleted file mode 100644 index b7ccc729d..000000000 --- a/activity_browser/app/actions/__init__.py +++ /dev/null @@ -1,100 +0,0 @@ -from .activity.activity_relink import ActivityRelink -from .activity.activity_duplicate import ActivityDuplicate -from .activity.activity_open import ActivityOpen -from .activity.activity_delete import ActivityDelete -from .activity.activity_duplicate_to_db import ActivityDuplicateToDB -from .activity.activity_modify import ActivityModify -from .activity.activity_new_process import ActivityNewProcess -from .activity.activity_new_product import ActivityNewProduct -from .activity.activity_open import ActivityOpen -from .activity.activity_relink import ActivityRelink -from .activity.activity_sdf_to_clipboard import ActivitySDFToClipboard -from .activity.process_property_modify import ProcessPropertyModify -from .activity.process_property_remove import ProcessPropertyRemove - -from .calculation_setup.cs_new import CSNew -from .calculation_setup.cs_delete import CSDelete -from .calculation_setup.cs_duplicate import CSDuplicate -from .calculation_setup.cs_rename import CSRename -from .calculation_setup.cs_add_functional_unit import CSAddFunctionalUnit -from .calculation_setup.cs_change_functional_unit import CSChangeFunctionalUnit -from .calculation_setup.cs_add_impact_category import CSAddImpactCategory -from .calculation_setup.cs_delete_impact_category import CSDeleteImpactCategory -from .calculation_setup.cs_delete_functional_unit import CSDeleteFunctionalUnit -from .calculation_setup.cs_calculate import CSCalculate -from .calculation_setup.cs_open import CSOpen - -from .database.database_open import DatabaseOpen -from .database.database_export_excel import DatabaseExportExcel -from .database.database_export_bw2package import DatabaseExportBW2Package -from .database.database_new import DatabaseNew -from .database.database_delete import DatabaseDelete -from .database.database_duplicate import DatabaseDuplicate -from .database.database_relink import DatabaseRelink -from .database.database_explorer_open import DatabaseExplorerOpen -from .database.database_process import DatabaseProcess -from .database.database_import_from_ecoinvent import DatabaseImportFromEcoinvent -from .database.database_importer_excel import DatabaseImporterExcel -from .database.database_importer_bw2package import DatabaseImporterBW2Package -from .database.database_set_readonly import DatabaseSetReadonly - -from .exchange.exchange_copy_sdf import ExchangeCopySDF - -from .exchange.exchange_new import ExchangeNew -from .exchange.exchange_delete import ExchangeDelete -from .exchange.exchange_modify import ExchangeModify -from .exchange.exchange_formula_remove import ExchangeFormulaRemove -from .exchange.exchange_uncertainty_modify import ExchangeUncertaintyModify -from .exchange.exchange_uncertainty_remove import ExchangeUncertaintyRemove -from .exchange.exchange_copy_sdf import ExchangeCopySDF -from .exchange.exchange_sdf_to_clipboard import ExchangeSDFToClipboard - -from .method.method_duplicate import MethodDuplicate -from .method.method_delete import MethodDelete -from .method.method_open import MethodOpen -from .method.method_rename import MethodRename -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.cf_uncertainty_modify import CFUncertaintyModify -from .method.cf_amount_modify import CFAmountModify -from .method.cf_remove import CFRemove -from .method.cf_new import CFNew -from .method.cf_uncertainty_remove import CFUncertaintyRemove - -from .parameter.parameter_new import ParameterNew -from .parameter.parameter_new_automatic import ParameterNewAutomatic -from .parameter.parameter_new_from_parameter import ParameterNewFromParameter -from .parameter.parameter_rename import ParameterRename -from .parameter.parameter_delete import ParameterDelete -from .parameter.parameter_modify import ParameterModify -from .parameter.parameter_uncertainty_remove import ParameterUncertaintyRemove -from .parameter.parameter_uncertainty_modify import ParameterUncertaintyModify -from .parameter.parameter_clear_broken import ParameterClearBroken -from .parameter.parameter_group_delete import ParameterGroupDelete - -from .project.project_new import ProjectNew -from .project.project_duplicate import ProjectDuplicate -from .project.project_delete import ProjectDelete -from .project.project_duplicate import ProjectDuplicate -from .project.project_remote_import import ProjectRemoteImport -from .project.project_local_import import ProjectLocalImport -from .project.project_new import ProjectNew -from .project.project_new_remote import ProjectNewRemote -from .project.project_switch import ProjectSwitch -from .project.project_export import ProjectExport -from .project.project_import import ProjectImport -from .project.project_manager_open import ProjectManagerOpen -from .project.project_migrate25 import ProjectMigrate25 -from .project.project_create_template import ProjectCreateTemplate -from .project.project_new_template import ProjectNewFromTemplate - -from .migrations_install import MigrationsInstall -from .pyside_upgrade import PysideUpgrade -from .metadatastore_open import MetaDataStoreOpen -from .node_select_open import NodeSelectOpen -from .save_parameters_to_excel import SaveParametersToExcel -from .metadatastore_cache_clear import MetaDataStoreCacheClear diff --git a/activity_browser/app/actions/activity/activity_delete.py b/activity_browser/app/actions/activity/activity_delete.py deleted file mode 100644 index 213e1186a..000000000 --- a/activity_browser/app/actions/activity/activity_delete.py +++ /dev/null @@ -1,97 +0,0 @@ -from typing import List - -from qtpy import QtWidgets - -import bw2data as bd -import bw_functional as bf - -from bw2data.parameters import (ActivityParameter, Group, - GroupDependency, - parameters) - -from activity_browser.app import application -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - -class ActivityDelete(ABAction): - """ - ABAction to delete one or multiple activities if supplied by activity keys. Will check if an activity has any - downstream processes and ask the user whether they want to continue if so. Exchanges from any downstream processes - will be removed - """ - - icon = qicons.delete - text = "Delete ***" - - @staticmethod - @exception_dialogs - def run(activity_keys: List[tuple]): - # retrieve activity objects from the controller using the provided keys - activities = [bd.get_activity(key) for key in activity_keys] - - warnings = [] - if len(activities) == 1: - warnings.append(f"Are you certain you want to delete {activities[0]['name']}?") - else: - warnings.append(f"Are you certain you want to delete {len(activities)} nodes?") - - warnings.append("") # add a blank line for readability - - if any(len(act.upstream()) > 0 for act in activities): - warnings.append("One or more of the activities you are trying to delete have consumers") - - if any([act for act in activities if isinstance(act, bf.Process)]): - warnings.append("Products of processes will be removed as well") - - warning_text = "
".join(warnings) - - # alert the user - choice = QtWidgets.QMessageBox.warning( - application.main_window, - build_title(activities), - warning_text, - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.No, - ) - - # return if the user cancels - if choice == QtWidgets.QMessageBox.No: - return - - for act in activities: - db, code = act.key - - try: - group_name = ActivityParameter.get( - (ActivityParameter.database == db) - & (ActivityParameter.code == code) - ).group - - # remove activity parameters from its group - parameters.remove_from_group(group_name, act) - - # Also clear the group if there are no more parameters in it - if ( - not ActivityParameter.select() - .where(ActivityParameter.group == group_name) - .exists() - ): - Group.get(Group.name == group_name).delete_instance() - GroupDependency.delete().where( - GroupDependency.group == group_name - ).execute() - except ActivityParameter.DoesNotExist: - # no parameters found for this activity - pass - - # Included in bw2data as of 4.1 - # act.upstream().delete() - - act.delete() - - -def build_title(activities: List[bd.Node]) -> str: - if len(activities) == 1: - return "Delete node" - return "Delete nodes" diff --git a/activity_browser/app/actions/activity/activity_duplicate.py b/activity_browser/app/actions/activity/activity_duplicate.py deleted file mode 100644 index 1197a994d..000000000 --- a/activity_browser/app/actions/activity/activity_duplicate.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Callable, List, Union - -from qtpy import QtCore - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils import commontasks -from bw2data import get_activity -from activity_browser.ui.icons import qicons - - -class ActivityDuplicate(ABAction): - """ - Duplicate one or multiple activities using their keys. Proxy action to call the controller. - """ - - icon = qicons.copy - text = "Duplicate ***" - - @staticmethod - @exception_dialogs - def run(activity_keys: List[tuple]): - activities = [get_activity(key) for key in activity_keys] - - for activity in activities: - new_code = commontasks.generate_copy_code(activity.key) - activity.copy(new_code) diff --git a/activity_browser/app/actions/activity/activity_duplicate_to_db.py b/activity_browser/app/actions/activity/activity_duplicate_to_db.py deleted file mode 100644 index d677590a1..000000000 --- a/activity_browser/app/actions/activity/activity_duplicate_to_db.py +++ /dev/null @@ -1,158 +0,0 @@ -from typing import List - -from qtpy import QtWidgets - -import bw2data as bd -import bw_functional as bf - -from activity_browser.app import application -from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons -from bw_functional import Product - -from .activity_open import ActivityOpen - - -class ActivityDuplicateToDB(ABAction): - icon = qicons.duplicate_to_other_database - text = "Duplicate to other database" - - @classmethod - @exception_dialogs - def run(cls, nodes: List[tuple | int | bd.Node], to_db_name: str = None): - nodes = [refresh_node(node) for node in nodes] - dbs = {node.get("database") for node in nodes} - from_db_name = next(iter(dbs)) - from_db_backend = bd.databases[from_db_name]["backend"] - - if not len(dbs) == 1: - raise ValueError("All selected activities must be from the same database.") - - if any([isinstance(node, bf.Product) for node in nodes]): - raise ValueError("Products cannot be duplicated to another database. Duplicate the parent process instead.") - - if to_db_name: - if not cls.confirm_db(to_db_name): - return - else: - to_db_name = cls.request_db(from_db_name) - - to_db_backend = bd.databases[to_db_name]["backend"] - - if from_db_backend == to_db_backend: - new_nodes = cls.duplicate_simple(nodes, to_db_name) - elif from_db_backend == "sqlite" and to_db_backend == "functional_sqlite": - new_nodes = cls.duplicate_sqlite_to_functional_sqlite(nodes, to_db_name) - elif from_db_backend == "functional_sqlite" and to_db_backend == "sqlite": - new_nodes = cls.duplicate_functional_sqlite_to_sqlite(nodes, to_db_name) - else: - raise NotImplementedError(f"Copying from {from_db_backend} to {to_db_backend} is not supported.") - - ActivityOpen.run(new_nodes) - - @staticmethod - def request_db(from_db_name: str) -> str | None: - # get valid databases (not the original database, or locked databases) - target_dbs = [ - db_name for db_name, meta in bd.databases.items() if - db_name != from_db_name - and meta.get("read_only", True) is not True - ] - - # return if there are no valid databases to duplicate to - if not target_dbs: - QtWidgets.QMessageBox.warning( - application.main_window, - "No target database", - "No valid target databases available. Create a new database or set one to writable (not read-only).", - ) - return - - # construct a dialog where the user can choose a database to duplicate to - target_db, ok = QtWidgets.QInputDialog.getItem( - application.main_window, - "Move node to database", - "Target database:", - target_dbs, - 0, - False, - ) - - return target_db if ok else None - - @staticmethod - def confirm_db(to_db_name: str): - user_choice = QtWidgets.QMessageBox.question( - application.main_window, - "Move to new database", - f"Move to {to_db_name} and open as new tab?", - ) - return user_choice == user_choice.Yes - - @staticmethod - def duplicate_simple(nodes: list[bd.Node], to_db_name: str) -> list[bd.Node]: - new_nodes = [] - - # move all supplied nodes to the db by copying them - for node in nodes: - new_node = node.copy(database=to_db_name) - new_nodes.append(new_node) - - return new_nodes - - @staticmethod - def duplicate_sqlite_to_functional_sqlite(nodes: list[bd.Node], to_db_name: str) -> list[bd.Node]: - from bw_functional.convert import SQLiteToFunctionalSQLite - new_nodes = [] - - for node in nodes: - dataset = node.as_dict() - - dataset.pop("id", None) - dataset.pop("key", None) - - dataset["exchanges"] = [exc.as_dict() for exc in node.exchanges()] - dataset["database"] = to_db_name # because we didn't copy the dict this will also be reflected in node.key - - new_datasets = SQLiteToFunctionalSQLite.convert_process(node.key, dataset, False) - new_exchanges = [x for ds in new_datasets.values() for x in ds.pop("exchanges", [])] - - for key, new_dataset in new_datasets.items(): - new_node = bd.Node(**new_dataset) - new_node.save() - new_nodes.append(new_node) - - for exc in new_exchanges: - exc["output"] = (to_db_name, exc["output"][1]) # relink output to new db - new_exc = bd.Edge(**exc) - new_exc.save() - - return new_nodes - - @staticmethod - def duplicate_functional_sqlite_to_sqlite(nodes: list[bd.Node], to_db_name: str) -> list[bd.Node]: - new_nodes = [] - products = [prod for node in nodes if isinstance(node, bf.Process) for prod in node.products()] - - for product in products: - dataset = product.as_dict() - dataset.pop("id", None) - dataset.pop("key", None) - - exchanges = product.virtual_edges - dataset["database"] = to_db_name - dataset["type"] = "processwithreferenceproduct" - - new_node = bd.Node(**dataset) - new_node.save() - new_nodes.append(new_node) - - for exc in exchanges: - exc["output"] = (to_db_name, exc["output"][1]) - if exc["type"] == "production": - exc["input"] = (to_db_name, new_node.key[1]) - new_exc = bd.Edge(**exc) - new_exc.save() - - return new_nodes diff --git a/activity_browser/app/actions/activity/activity_modify.py b/activity_browser/app/actions/activity/activity_modify.py deleted file mode 100644 index 4eb54fa98..000000000 --- a/activity_browser/app/actions/activity/activity_modify.py +++ /dev/null @@ -1,27 +0,0 @@ -from activity_browser.app.actions.base import ABAction, exception_dialogs -from bw2data import get_node, Node -from activity_browser.ui.icons import qicons -from activity_browser.bwutils.commontasks import refresh_node - - -class ActivityModify(ABAction): - """ - ABAction to delete one or multiple activities if supplied by activity keys. Will check if an activity has any - downstream processes and ask the user whether they want to continue if so. Exchanges from any downstream processes - will be removed - """ - - icon = qicons.edit - text = "Modify Activity" - - @staticmethod - @exception_dialogs - def run(activity: tuple | int | Node, field: str, value: any): - activity = refresh_node(activity) - - if field == "product": - # for some reason product needs to be set like this - field = "reference product" - - activity[field] = value - activity.save() diff --git a/activity_browser/app/actions/activity/activity_new_process.py b/activity_browser/app/actions/activity/activity_new_process.py deleted file mode 100644 index 79725152c..000000000 --- a/activity_browser/app/actions/activity/activity_new_process.py +++ /dev/null @@ -1,130 +0,0 @@ -from uuid import uuid4 - -from qtpy import QtWidgets -import bw2data as bd - -from activity_browser import app -from activity_browser.bwutils.commontasks import database_is_legacy -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - -from .activity_open import ActivityOpen - - -class ActivityNewProcess(ABAction): - """ - ABAction to create a new activity. Prompts the user to supply a name. Returns if no name is supplied or if the user - cancels. Otherwise, instructs the ActivityController to create a new activity. - """ - - icon = qicons.add - text = "New process" - - @staticmethod - @exception_dialogs - def run(database_name: str): - # ask the user to provide a name for the new activity - dialog = NewNodeDialog(app.main_window) - # if the user cancels, return - if dialog.exec_() != QtWidgets.QDialog.DialogCode.Accepted: - return - name, ref_product, unit, location = dialog.get_new_process_data() - # if no name is provided, return - if not name: - return - if ref_product == "": - ref_product = name - - database = bd.Database(database_name) - legacy_backend = database_is_legacy(database_name) - - # create process - new_proc_data = { - "name": name, - "location": location, - "type": "process" if not legacy_backend else "processwithreferenceproduct", - } - - if legacy_backend: - new_proc_data["reference product"] = ref_product - new_proc_data["unit"] = unit - - new_process: bd.Node = database.new_activity(code=uuid4().hex, **new_proc_data) - new_process.save() - - if legacy_backend: - new_process.new_exchange( - input=new_process.key, - type="production", - amount=1.0, - ).save() - - if not legacy_backend: - # create reference product - new_ref_prod_data = { - "product": ref_product, - "unit": unit, - "location": location, - "type": "product", - } - prod = new_process.new_product(code=uuid4().hex, **new_ref_prod_data) - prod.save() - - ActivityOpen.run([new_process.key]) - - -class NewNodeDialog(QtWidgets.QDialog): - """ - Gathers the paremeters for creating a new process. - """ - - def __init__(self, process: bool = True, parent = None): - super().__init__(parent) - layout = QtWidgets.QGridLayout() - row = 0 - if process: - self.setWindowTitle("New process") - layout.addWidget(QtWidgets.QLabel("Process name"), row, 0) - else: - self.setWindowTitle("New product") - layout.addWidget(QtWidgets.QLabel("Product name"), row, 0) - self._process_name_edit = QtWidgets.QLineEdit() - self._process_name_edit.textChanged.connect(self._handle_text_changed) - layout.addWidget(self._process_name_edit, row, 1) - row += 1 - self._ref_product_name_edit = QtWidgets.QLineEdit() - if process: - layout.addWidget(QtWidgets.QLabel("Product name"), row, 0) - layout.addWidget(self._ref_product_name_edit, row, 1) - row += 1 - layout.addWidget(QtWidgets.QLabel("Unit"), row, 0) - self._unit_edit = QtWidgets.QLineEdit("kilogram") - layout.addWidget(self._unit_edit, row, 1) - row += 1 - layout.addWidget(QtWidgets.QLabel("Location"), row, 0) - default_loc = "GLO" if process else "" - self._location_edit = QtWidgets.QLineEdit(default_loc) - layout.addWidget(self._location_edit, row, 1) - row += 1 - self._ok_button = QtWidgets.QPushButton("OK") - self._ok_button.clicked.connect(self.accept) - self._ok_button.setEnabled(False) - layout.addWidget(self._ok_button, row, 0) - cancel_button = QtWidgets.QPushButton("Cancel") - cancel_button.clicked.connect(self.reject) - layout.addWidget(cancel_button, row, 1) - self.setLayout(layout) - - def _handle_text_changed(self, text: str): - self._ok_button.setEnabled(text != "") - self._ref_product_name_edit.setPlaceholderText(text) - - def get_new_process_data(self) -> tuple[str, str, str, str]: - """Return the parameters the user entered.""" - return ( - self._process_name_edit.text(), - self._ref_product_name_edit.text(), - self._unit_edit.text(), - self._location_edit.text() - ) - diff --git a/activity_browser/app/actions/activity/activity_new_product.py b/activity_browser/app/actions/activity/activity_new_product.py deleted file mode 100644 index 305ebb39d..000000000 --- a/activity_browser/app/actions/activity/activity_new_product.py +++ /dev/null @@ -1,154 +0,0 @@ -from uuid import uuid4 - -from qtpy import QtWidgets - -import bw2data as bd - -from bw_functional import Process - -from activity_browser import app -from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - -class ActivityNewProduct(ABAction): - """ - ABAction to create a new product for an activity. - - This action prompts the user to supply a name, unit, and location for the new product. - If the user cancels or does not provide a name, the action is aborted. - Otherwise, it creates a new product associated with the given activity. - - Attributes: - icon (QIcon): The icon representing this action. - text (str): The display text for this action. - """ - - icon = qicons.add - text = "Create product" - - @staticmethod - @exception_dialogs - def run(activities: list[tuple | int | bd.Node], product_type: str = "product"): - """ - Execute the action to create a new product. - - This method iterates over the provided activities, ensuring each is a `Process`. - It prompts the user to input details for the new product. If valid details are provided, - a new product is created and saved. - - Args: - activities (list[tuple | int | bd.Node]): A list of activities to process. - product_type (str, optional): The type of the new product. Defaults to "product". - - Raises: - AssertionError: If an activity is not of type `Process`. - """ - activities = [refresh_node(activity) for activity in activities] - - for act in activities: - assert isinstance(act, Process), "Cannot create new product for non-process type" - # Ask the user to provide a name for the new product - dialog = NewProductDialog(act, product_type, app.main_window) - # If the user cancels, skip to the next activity - if dialog.exec_() != QtWidgets.QDialog.Accepted: - continue - name, unit, location = dialog.get_new_process_data() - # If no name is provided, skip to the next activity - if not name: - continue - - # Create the new product - new_prod_data = { - "product": name, - "unit": unit, - "location": location, - "type": product_type, - } - new_product = act.new_product(code=uuid4().hex, **new_prod_data) - new_product.save() - - -class NewProductDialog(QtWidgets.QDialog): - """ - A dialog for gathering parameters to create a new product. - - This dialog allows the user to input the product name, unit, and location. - It validates the input and provides options to either create the product or cancel the operation. - """ - - def __init__(self, activity: bd.Node, product_type: str, parent: QtWidgets.QWidget = None): - """ - Initialize the NewProductDialog. - - Args: - activity (bd.Node): The activity for which the product is being created. - Used to prefill the location field and set the dialog title. - product_type (str): The type of the new product ("product", "waste"). - parent (QtWidgets.QWidget, optional): The parent widget for the dialog. Defaults to None. - """ - super().__init__(parent) - - # Set the dialog window title - self.setWindowTitle(f"Create {product_type} for {activity['name']}") - - # Input fields for product details - self.name_edit = QtWidgets.QLineEdit() - self.unit_edit = QtWidgets.QLineEdit("kilogram") # Default unit is "kilogram" - self.location_edit = QtWidgets.QLineEdit(activity.get("location", "")) # Prefill location if available - - # Buttons for user actions - self.ok_button = QtWidgets.QPushButton("Create") - self.ok_button.clicked.connect(self.accept) # Connect the "Create" button to accept the dialog - self.ok_button.setEnabled(False) # Initially disable the button until a name is entered - - self.cancel_button = QtWidgets.QPushButton("Cancel") - self.cancel_button.clicked.connect(self.reject) # Connect the "Cancel" button to reject the dialog - - # Set up signals and layout - self.connect_signals() - self.build_layout() - - def connect_signals(self): - """ - Connect signals to their respective handlers. - - - Enables the "Create" button only when the name field is not empty. - """ - self.name_edit.textChanged.connect(lambda x: self.ok_button.setEnabled(bool(x))) - - def build_layout(self): - """ - Build and set the layout for the dialog. - - The layout includes labels and input fields for product name, unit, and location, - as well as "Create" and "Cancel" buttons. - """ - layout = QtWidgets.QGridLayout() - layout.addWidget(QtWidgets.QLabel("Name"), 0, 0) - layout.addWidget(self.name_edit, 0, 1) - - layout.addWidget(QtWidgets.QLabel("Unit"), 1, 0) - layout.addWidget(self.unit_edit, 1, 1) - - layout.addWidget(QtWidgets.QLabel("Location"), 2, 0) - layout.addWidget(self.location_edit, 2, 1) - - layout.addWidget(self.ok_button, 3, 0) - layout.addWidget(self.cancel_button, 3, 1) - - self.setLayout(layout) - - def get_new_process_data(self) -> tuple[str, str, str]: - """ - Retrieve the parameters entered by the user. - - Returns: - tuple[str, str, str]: A tuple containing the product name, unit, and location. - """ - return ( - self.name_edit.text(), - self.unit_edit.text(), - self.location_edit.text() - ) diff --git a/activity_browser/app/actions/activity/activity_open.py b/activity_browser/app/actions/activity/activity_open.py deleted file mode 100644 index fe270d1dd..000000000 --- a/activity_browser/app/actions/activity/activity_open.py +++ /dev/null @@ -1,63 +0,0 @@ -from loguru import logger - -import bw2data as bd -import bw_functional as bf - -from activity_browser import app -from activity_browser.bwutils.commontasks import refresh_node, is_node_process -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - - - -class ActivityOpen(ABAction): - """ - ABAction to open one or more activities. - - This action processes a list of activities, validates their types, and opens - their details in the application's central widget. Unsupported activity types - are logged as warnings. - - Attributes: - icon (QIcon): The icon representing this action. - text (str): The display text for this action. - """ - - icon = qicons.right - text = "Open activity / activities" - - @staticmethod - @exception_dialogs - def run(activities: list[tuple | int | bd.Node]): - """ - Execute the action to open activities. - - This method refreshes the provided activities, validates their types, and - opens their details in the "Activity Details" group of the central widget. - - Args: - activities (list[tuple | int | bd.Node]): A list of activities to process. - - Logs: - Warning: If an activity type is not supported. - """ - from activity_browser.app import pages - - # Refresh the activity nodes to ensure they are up-to-date - activities = [refresh_node(activity) for activity in activities] - processes = [refresh_node(function["processor"]) for function in activities if isinstance(function, bf.Product)] - activities = list(set(activities + processes)) - - for act in activities: - # Check if the activity type is supported - if not is_node_process(act): - logger.warning(f"Can't open activity {act.key} - opening type: `{act.get('type')}` not supported") - continue - - # Create a details page for the activity - page = pages.ActivityDetailsPage(act) - central = app.main_window.centralWidget() - - # Add the details page to the "Activity Details" group in the central widget - central.addToGroup("Activity Details", page) diff --git a/activity_browser/app/actions/activity/activity_relink.py b/activity_browser/app/actions/activity/activity_relink.py deleted file mode 100644 index 69038ff09..000000000 --- a/activity_browser/app/actions/activity/activity_relink.py +++ /dev/null @@ -1,220 +0,0 @@ -from typing import List - -from qtpy import QtCore, QtWidgets - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils.strategies import relink_activity_exchanges -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - -class ActivityRelink(ABAction): - """ - ABAction to relink the exchanges of an activity to exchanges from another database. - - This action only uses the first key from activity_keys - """ - - icon = qicons.edit - text = "Relink the activity exchanges" - - @staticmethod - @exception_dialogs - def run(activity_keys: List[tuple]): - # this action only uses the first key supplied to activity_keys - key = activity_keys[0] - - # extract the brightway database and activity - db = bd.Database(key[0]) - activity = bd.get_activity(key) - - # find the dependents for the database and construct the alternatives in tuple format - depends = db.find_dependents() - options = [(depend, list(bd.databases)) for depend in depends] - - # present the alternatives to the user in a linking dialog - dialog = ActivityLinkingDialog.relink_sqlite( - activity["name"], options, app.main_window - ) - - # return if the user cancels - if dialog.exec_() == ActivityLinkingDialog.Rejected: - return - - # relinking will take some time, set WaitCursor - QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) - - # use the relink_activity_exchanges strategy to relink the exchanges of the activity - relinking_results = {} - for old, new in dialog.relink.items(): - other = bd.Database(new) - failed, succeeded, examples = relink_activity_exchanges( - activity, old, other - ) - relinking_results[f"{old} --> {other.name}"] = (failed, succeeded) - - # restore normal cursor - QtWidgets.QApplication.restoreOverrideCursor() - - # if any relinks failed present them to the user - if failed > 0: - relinking_dialog = ActivityLinkingResultsDialog.present_relinking_results( - app.main_window, relinking_results, examples - ) - relinking_dialog.exec_() - - -class ActivityLinkingDialog(QtWidgets.QDialog): - """ - Displays the possible databases for relinking the exchanges for a given activity - """ - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Activity linking") - - self.db_label = QtWidgets.QLabel() - self.label_choices = [] - self.grid_box = QtWidgets.QGroupBox("Database links:") - self.grid = QtWidgets.QGridLayout() - self.grid_box.setLayout(self.grid) - - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.db_label) - layout.addWidget(self.grid_box) - layout.addWidget(self.buttons) - self.setLayout(layout) - - @property - def relink(self) -> dict: - """Returns a dictionary of str -> str key/values, showing which keys - should be linked to which values. - - Only returns key/value pairs if they differ. - """ - return { - label.text(): combo.currentText() - for label, combo in self.label_choices - if label.text() != combo.currentText() - } - - @property - def links(self) -> dict: - """Returns a dictionary of str -> str key/values, showing which keys - should be linked to which values. - """ - return { - label.text(): combo.currentText() for label, combo in self.label_choices - } - - @classmethod - def construct_dialog( - cls, - label: str, - options: list, - parent: QtWidgets.QWidget = None, - ) -> "ActivityLinkingDialog": - obj = cls(parent) - obj.db_label.setText(label) - # Start at 1 because row 0 is taken up by the db_label - for i, item in enumerate(options): - label = QtWidgets.QLabel(item[0]) - combo = QtWidgets.QComboBox() - combo.addItems(item[1]) - combo.setCurrentText(item[0]) - obj.label_choices.append((label, combo)) - obj.grid.addWidget(label, i, 0, 1, 2) - obj.grid.addWidget(combo, i, 2, 1, 2) - obj.updateGeometry() - return obj - - @classmethod - def relink_sqlite( - cls, act: str, options: list, parent=None - ) -> "ActivityLinkingDialog": - label = "Relinking exchanges from activity '{}'.".format(act) - return cls.construct_dialog(label, options, parent) - - -class ActivityLinkingResultsDialog(QtWidgets.QDialog): - """ - Provides a summary from a relinking of activity exchanges for the relinking of a - single activity. - A simple design layout based on the DatabaseLinkingResultsDialog - """ - - def __init__(self, parent=None): - super().__init__(parent) - - self.setWindowTitle("Relinking database results") - - button = QtWidgets.QDialogButtonBox.Ok - self.buttonBox = QtWidgets.QDialogButtonBox(button) - self.buttonBox.accepted.connect(self.accept) - self.databases_relinked = QtWidgets.QVBoxLayout() - - self.activityToOpen = set() - - self.exchangesUnlinked = QtWidgets.QVBoxLayout() - - self.layout = QtWidgets.QVBoxLayout() - self.layout.addLayout(self.databases_relinked) - self.layout.addLayout(self.exchangesUnlinked) - self.layout.addWidget(self.buttonBox) - self.setLayout(self.layout) - - @classmethod - def construct_results_dialog( - cls, - parent: QtWidgets.QWidget = None, - link_results: dict = None, - unlinked_exchanges: dict = None, - ) -> "ActivityLinkingResultsDialog": - from activity_browser import app - - obj = cls(parent) - for k, results in link_results.items(): - obj.databases_relinked.addWidget( - QtWidgets.QLabel(f"{k} = {results[1]} successfully linked") - ) - obj.databases_relinked.addWidget( - QtWidgets.QLabel(f"{k} = {results[0]} flows failed to link") - ) - - obj.exchangesUnlinked.addWidget( - QtWidgets.QLabel("Up to 5 unlinked exchanges (click to open)") - ) - for act, key in unlinked_exchanges.items(): - button = QtWidgets.QPushButton(act.as_dict()["name"]) - button.clicked.connect( - lambda: app.actions.ActivityOpen.run([act.key]) - ) - obj.exchangesUnlinked.addWidget(button) - obj.updateGeometry() - - return obj - - @classmethod - def present_relinking_results( - cls, - parent: QtWidgets.QWidget = None, - link_results: dict = None, - unlinked_exchanges: dict = None, - ) -> "ActivityLinkingResultsDialog": - return cls.construct_results_dialog(parent, link_results, unlinked_exchanges) - - def select_activity_to_open(self, actvty: tuple) -> None: - if actvty in self.activityToOpen: - self.activityToOpen.discard(actvty) - self.activityToOpen.add(actvty) - - def open_activity(self): - return self.activityToOpen - diff --git a/activity_browser/app/actions/activity/activity_sdf_to_clipboard.py b/activity_browser/app/actions/activity/activity_sdf_to_clipboard.py deleted file mode 100644 index f74a5c690..000000000 --- a/activity_browser/app/actions/activity/activity_sdf_to_clipboard.py +++ /dev/null @@ -1,37 +0,0 @@ -from typing import List - -import bw2data as bd -import bw_functional as bf - -from activity_browser.bwutils.commontasks import refresh_node, exchanges_to_sdf -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - -class ActivitySDFToClipboard(ABAction): - """ - ABAction to open one or more supplied activities in an activity tab by employing signals. - - TODO: move away from using signals like this. Probably add a method to the MainWindow to add a panel instead. - """ - - icon = qicons.superstructure - text = "SDF to clipboard" - - @staticmethod - @exception_dialogs - def run(activities: List[tuple | int | bd.Node]): - activities = [refresh_node(node) for node in activities] - - exchanges = [] - for activity in activities: - if isinstance(activity, bf.Product): - exchanges += activity.virtual_edges - if isinstance(activity, bf.Process): - for product in activity.products(): - exchanges += product.virtual_exchanges - else: - exchanges += [exc.as_dict() for exc in activity.exchanges()] - - df = exchanges_to_sdf(exchanges) - df.to_clipboard(excel=True, index=False) diff --git a/activity_browser/app/actions/activity/process_property_modify.py b/activity_browser/app/actions/activity/process_property_modify.py deleted file mode 100644 index 26e60b30c..000000000 --- a/activity_browser/app/actions/activity/process_property_modify.py +++ /dev/null @@ -1,136 +0,0 @@ -from qtpy import QtWidgets, QtCore - -from activity_browser import app -from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - -from bw_functional import Process - - -class ProcessPropertyModify(ABAction): - """ - Modify a property for all the products of a process. - - This method refreshes the given process, validates its type, and opens a dialog - for the user to modify a property. If the property already exists, the dialog - is pre-populated with its current values. The updated property is then applied - to all products of the process. - - Args: - process (tuple | int | Process): The process to modify. Can be a tuple, integer, or Process object. - property_name (str, optional): The name of the property to modify. Defaults to None. - - Raises: - ValueError: If the provided process is not of type Process. - """ - - icon = qicons.edit - text = "Modify property" - - @staticmethod - @exception_dialogs - def run(process: tuple | int | Process, - property_name: str = None - ): - - process = refresh_node(process) - if not isinstance(process, Process): - raise ValueError(f"Expected a Process-type activity, got {type(process)} instead") - - prop_dialog = PropertyDialog(process) - - # if the property already exists, populate the dialog with the existing values - if property_name in process.available_properties(): - prop = process.property_template(property_name) - prop_dialog.prop_name.setText(property_name) - prop_dialog.prop_unit.setText(prop["unit"]) - prop_dialog.normalize_check.setChecked(prop.get("normalize", True)) - - # show the dialog to the user - if prop_dialog.exec_() != QtWidgets.QDialog.DialogCode.Accepted: - return - - name_changed = prop_dialog.name != property_name if property_name else False - - for product in process.products(): - # make sure the dictionaries are in place - product["properties"] = product.get("properties", {}) - product["properties"][prop_dialog.name] = product["properties"].get(property_name, {}) - - prop = { - "unit": prop_dialog.prop["unit"], - "normalize": prop_dialog.prop["normalize"], - "amount": product["properties"][prop_dialog.name].get("amount", 1.0), - } - - # update the property with the new values - product["properties"][prop_dialog.name] = prop - - # if the name has changed, remove the old property - if name_changed and property_name in product["properties"]: - del product["properties"][property_name] - - product.save() - - -class PropertyDialog(QtWidgets.QDialog): - name: str | None = None - prop: dict | None = None - - def __init__(self, process: Process): - super().__init__(app.main_window) - self.process = process - - self.setWindowTitle("Add Property") - - self.prop_name = QtWidgets.QLineEdit(self) - self.prop_name.setPlaceholderText("Property name") - self.prop_name.textChanged.connect(self.validate) - - self.prop_unit = QtWidgets.QLineEdit(self) - self.prop_unit.setPlaceholderText("Property unit") - self.prop_unit.textChanged.connect(self.validate) - - self.normalize_label = QtWidgets.QLabel(" / amount", self) - self.normalize_label.setVisible(False) - self.normalize_check = QtWidgets.QCheckBox("per amount") - self.normalize_check.setChecked(True) - self.normalize_check.toggled.connect(self.normalize_label.setVisible) - self.normalize_check.toggled.connect(self.validate) - - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - h_layout = QtWidgets.QHBoxLayout() - h_layout.addWidget(self.prop_unit) - h_layout.addWidget(self.normalize_label) - - self.layout = QtWidgets.QVBoxLayout() - self.layout.addWidget(self.prop_name) - self.layout.addLayout(h_layout) - self.layout.addWidget(self.normalize_check, alignment=QtCore.Qt.AlignmentFlag.AlignRight) - self.layout.addWidget(self.buttons) - - self.setLayout(self.layout) - - def validate(self): - if ( - self.prop_name.text() and - self.prop_unit.text() and - self.prop_name.text() not in self.process.get("properties", []) - ): - self.name = self.prop_name.text() - self.prop = { - "unit": self.prop_unit.text(), - "normalize": self.normalize_check.isChecked(), - } - self.buttons.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True) - else: - self.name = None - self.prop = None - self.buttons.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False) diff --git a/activity_browser/app/actions/activity/process_property_remove.py b/activity_browser/app/actions/activity/process_property_remove.py deleted file mode 100644 index c3c731569..000000000 --- a/activity_browser/app/actions/activity/process_property_remove.py +++ /dev/null @@ -1,60 +0,0 @@ -from loguru import logger - -from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - -from bw_functional import Process -from bw2data import databases - - - - -class ProcessPropertyRemove(ABAction): - """ - Remove a specified property from a process and its associated products. - - This method refreshes the given process, validates its type, and checks if the specified - property exists. If the property is an allocation property, it resets the allocation to - the database's default allocation. The property is then removed from all products of the process. - - Args: - process (tuple | int | Process): The process from which the property will be removed. - Can be a tuple (key), integer (id), or Process object. - property_name (str): The name of the property to remove. - - Raises: - ValueError: If the provided process is not of type Process. - - Logs: - Warning: If the specified property is not found in the process. - """ - - icon = qicons.delete - text = "Remove property" - - @staticmethod - @exception_dialogs - def run(process: tuple | int | Process, property_name: str): - process = refresh_node(process) - if not isinstance(process, Process): - raise ValueError(f"Expected a Process-type activity, got {type(process)} instead") - - allocate = property_name == process.get("allocation") - - if property_name not in process.available_properties(): - logger.warning(f"Property '{property_name}' not found in process {process.key}.") - return - - if allocate: - process["allocation"] = databases[process["database"]].get("default_allocation", "equal") - process.save() - - # Remove the property from all products of the process - for product in process.products(): - if property_name not in product.get("properties", {}): - continue - - del product["properties"][property_name] - product.save() - diff --git a/activity_browser/app/actions/base.py b/activity_browser/app/actions/base.py deleted file mode 100644 index 1872a2cf5..000000000 --- a/activity_browser/app/actions/base.py +++ /dev/null @@ -1,61 +0,0 @@ -from loguru import logger -from qtpy import QtCore, QtGui, QtWidgets - -from activity_browser import app - - - - -class ABAction: - icon = QtGui.QIcon() - text: str = None - tooltip: str = None - - @staticmethod - def run(*args, **kwargs): - raise NotImplementedError - - @classmethod - def triggered(cls, *args, **kwargs): - args = [arg if not callable(arg) else arg() for arg in args] - kwargs = {k: v if not callable(v) else v() for k, v in kwargs.items()} - - cls.run(*args, **kwargs) - - @classmethod - def get_QAction(cls, *args, parent=None, text=None, enabled=True, **kwargs) -> QtWidgets.QAction: - text = text or cls.text - action = QtWidgets.QAction(cls.icon, text, parent, enabled=enabled) - action.setToolTip(cls.tooltip) - - action.triggered.connect(lambda: cls.triggered(*args, **kwargs)) - - return action - - @classmethod - def get_QButton(cls, *args, **kwargs): - """Convenience function to return a button that has this ABAction as default action.""" - button = QtWidgets.QPushButton( - cls.icon, - cls.text - ) - button.clicked.connect(lambda x: cls.triggered(*args, **kwargs)) - return button - - -def exception_dialogs(func): - def wrapper(*args, **kwargs): - try: - func(*args, **kwargs) - except Exception as e: - if not hasattr(e, "dialog_flag"): - setattr(e, "dialog_flag", True) - QtWidgets.QMessageBox.critical( - app.main_window, - f"An error occurred: {type(e).__name__}", - f"An error occurred, check the logs for more information \n\n {str(e)}", - QtWidgets.QMessageBox.Ok, - ) - raise e - - return wrapper diff --git a/activity_browser/app/actions/calculation_setup/cs_add_functional_unit.py b/activity_browser/app/actions/calculation_setup/cs_add_functional_unit.py deleted file mode 100644 index 2171ee4f7..000000000 --- a/activity_browser/app/actions/calculation_setup/cs_add_functional_unit.py +++ /dev/null @@ -1,23 +0,0 @@ -from loguru import logger - -from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd - - - - -class CSAddFunctionalUnit(ABAction): - text = "Add Functional Unit to Calculation Setup" - - @staticmethod - @exception_dialogs - def run(cs_name: str, activities: list[tuple | int | bd.Node]): - activities = [refresh_node(node) for node in activities] - calculation_setup = bd.calculation_setups[cs_name] - - fus = [{act.key: -1.0 if act.get("type") == "waste" else 1.0} for act in activities] - calculation_setup['inv'] += fus - - bd.calculation_setups[cs_name] = calculation_setup - bd.calculation_setups.serialize() diff --git a/activity_browser/app/actions/calculation_setup/cs_add_impact_category.py b/activity_browser/app/actions/calculation_setup/cs_add_impact_category.py deleted file mode 100644 index 69a5a34c5..000000000 --- a/activity_browser/app/actions/calculation_setup/cs_add_impact_category.py +++ /dev/null @@ -1,21 +0,0 @@ -from loguru import logger - -import bw2data as bd - -from activity_browser.app.actions.base import ABAction, exception_dialogs - - - - -class CSAddImpactCategory(ABAction): - text = "Add Impact Category to Calculation Setup" - - @staticmethod - @exception_dialogs - def run(cs_name: str, ic_names: list[str]): - calculation_setup = bd.calculation_setups[cs_name] - - calculation_setup['ia'] += ic_names - - bd.calculation_setups[cs_name] = calculation_setup - bd.calculation_setups.serialize() diff --git a/activity_browser/app/actions/calculation_setup/cs_calculate.py b/activity_browser/app/actions/calculation_setup/cs_calculate.py deleted file mode 100644 index f5e8830eb..000000000 --- a/activity_browser/app/actions/calculation_setup/cs_calculate.py +++ /dev/null @@ -1,76 +0,0 @@ -from loguru import logger - -import pandas as pd -import bw2data as bd - -from qtpy import QtCore, QtWidgets - -from activity_browser import app -from activity_browser.bwutils.multilca import MLCA, Contributions -from activity_browser.bwutils.superstructure import SuperstructureMLCA, SuperstructureContributions -from activity_browser.bwutils.montecarlo import MonteCarloLCA -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - - - -class CSCalculate(ABAction): - """ - ABAction to calculate a calculation setup. First asks the user for confirmation and returns if cancelled. Otherwise, - passes the csname to the CalculationSetupController for calculation. Finally, displays confirmation that it succeeded. - """ - - icon = qicons.calculate - text = "Calculate" - - @staticmethod - @exception_dialogs - def run(cs_name: str, scenario_data: pd.DataFrame = None): - from activity_browser.app import pages - - # Check if the calculation setup is complete - if cs_name not in bd.calculation_setups: - raise Exception(f"Calculation setup '{cs_name}' not found.") - cs = bd.calculation_setups[cs_name] - if not cs.get("inv"): - raise Exception(f"Calculation setup '{cs_name}' has no functional units.") - if not cs.get("ia"): - raise Exception(f"Calculation setup '{cs_name}' has no impact assessment methods.") - - dialog = CalculationDialog(cs_name, app.main_window) - dialog.show() - app.application.thread().eventDispatcher().processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) - - try: - if scenario_data is None: - mlca = MLCA(cs_name) - contributions = Contributions(mlca) - else: - mlca = SuperstructureMLCA(cs_name, scenario_data) - contributions = SuperstructureContributions(mlca) - - mlca.calculate() - mc = MonteCarloLCA(cs_name) - - page = pages.LCAResultsPage(cs_name, mlca, contributions, mc) - central = app.main_window.centralWidget() - except: - dialog.close() - raise - - dialog.close() - central.addToGroup("LCA Results", page) - - -class CalculationDialog(QtWidgets.QDialog): - def __init__(self, cs_name: str, parent=None): - super().__init__(parent, QtCore.Qt.WindowTitleHint) - self.setWindowTitle(f"Running Calculations") - self.setModal(True) - - self.label = QtWidgets.QLabel(f"Running calculations for setup: {cs_name}", self) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(self.label) - self.setLayout(layout) diff --git a/activity_browser/app/actions/calculation_setup/cs_change_functional_unit.py b/activity_browser/app/actions/calculation_setup/cs_change_functional_unit.py deleted file mode 100644 index f7029170a..000000000 --- a/activity_browser/app/actions/calculation_setup/cs_change_functional_unit.py +++ /dev/null @@ -1,40 +0,0 @@ -from loguru import logger - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd - - - - -class CSChangeFunctionalUnit(ABAction): - """ - Updates the functional unit amount for a specific inventory item in a calculation setup. - - This method modifies the amount of a functional unit in the inventory of a given calculation setup - and saves the updated setup. - - Args: - cs_name (str): The name of the calculation setup to modify. - index (int): The index of the inventory item within the calculation setup. - amount (float): The new amount to set for the functional unit. - - Steps: - - Retrieve the calculation setup by its name. - - Extract the key of the inventory item at the specified index. - - Update the amount for the specified inventory item. - - Serialize and save the updated calculation setup. - - Raises: - Exception: If an error occurs during the process, it is handled by the `exception_dialogs` decorator. - """ - text = "Add Functional Unit to Calculation Setup" - - @staticmethod - @exception_dialogs - def run(cs_name: str, index: int, amount: float): - calculation_setup = bd.calculation_setups[cs_name] - - key = list(calculation_setup['inv'][index].keys())[0] - calculation_setup['inv'][index][key] = amount - - bd.calculation_setups.serialize() diff --git a/activity_browser/app/actions/calculation_setup/cs_delete.py b/activity_browser/app/actions/calculation_setup/cs_delete.py deleted file mode 100644 index 47b88ab29..000000000 --- a/activity_browser/app/actions/calculation_setup/cs_delete.py +++ /dev/null @@ -1,47 +0,0 @@ -from loguru import logger - -from qtpy import QtWidgets - -from activity_browser.app import application -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - - - -class CSDelete(ABAction): - """ - ABAction to delete a calculation setup. First asks the user for confirmation and returns if cancelled. Otherwise, - passes the csname to the CalculationSetupController for deletion. Finally, displays confirmation that it succeeded. - """ - - icon = qicons.delete - text = "Delete" - - @staticmethod - @exception_dialogs - def run(cs_names: str | list[str]): - if isinstance(cs_names, str): - cs_names = [cs_names] - - # ask the user whether they are sure to delete the calculation setup - warning = QtWidgets.QMessageBox.warning( - application.main_window, - f"Deleting Calculation Setup(s): {', '.join(cs_names)}", - "Are you sure?", - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.No, - ) - - # return if the users cancels - if warning == QtWidgets.QMessageBox.No: - return - - for cs_name in cs_names: - if cs_name not in bd.calculation_setups: - logger.warning(f"Calculation setup {cs_name} not found") - continue - - del bd.calculation_setups[cs_name] - logger.info(f"Deleted calculation setup: {cs_name}") diff --git a/activity_browser/app/actions/calculation_setup/cs_delete_functional_unit.py b/activity_browser/app/actions/calculation_setup/cs_delete_functional_unit.py deleted file mode 100644 index 654f311e2..000000000 --- a/activity_browser/app/actions/calculation_setup/cs_delete_functional_unit.py +++ /dev/null @@ -1,22 +0,0 @@ -from loguru import logger - -import bw2data as bd - -from activity_browser.app.actions.base import ABAction, exception_dialogs - - - - -class CSDeleteFunctionalUnit(ABAction): - text = "Delete Functional Unit" - - @staticmethod - @exception_dialogs - def run(cs_name: str, indices: list[int]): - calculation_setup = bd.calculation_setups[cs_name] - - for index in sorted(set(indices), reverse=True): - del calculation_setup['inv'][index] - - bd.calculation_setups[cs_name] = calculation_setup - bd.calculation_setups.serialize() diff --git a/activity_browser/app/actions/calculation_setup/cs_delete_impact_category.py b/activity_browser/app/actions/calculation_setup/cs_delete_impact_category.py deleted file mode 100644 index fa4ad031a..000000000 --- a/activity_browser/app/actions/calculation_setup/cs_delete_impact_category.py +++ /dev/null @@ -1,22 +0,0 @@ -from loguru import logger - -import bw2data as bd - -from activity_browser.app.actions.base import ABAction, exception_dialogs - - - - -class CSDeleteImpactCategory(ABAction): - text = "Delete Impact Category" - - @staticmethod - @exception_dialogs - def run(cs_name: str, indices: list[int]): - calculation_setup = bd.calculation_setups[cs_name] - - for index in sorted(set(indices), reverse=True): - del calculation_setup['ia'][index] - - bd.calculation_setups[cs_name] = calculation_setup - bd.calculation_setups.serialize() diff --git a/activity_browser/app/actions/calculation_setup/cs_duplicate.py b/activity_browser/app/actions/calculation_setup/cs_duplicate.py deleted file mode 100644 index aa49be61a..000000000 --- a/activity_browser/app/actions/calculation_setup/cs_duplicate.py +++ /dev/null @@ -1,49 +0,0 @@ -from loguru import logger - -from qtpy import QtWidgets - -from activity_browser.app import application -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - -from .cs_open import CSOpen - - - -class CSDuplicate(ABAction): - """ - ABAction to duplicate a calculation setup. Prompts the user for a new name. Returns if the user cancels, or if a CS - with the same name is already present within the project. If all is right, instructs the CalculationSetupController - to duplicate the CS. - """ - - icon = qicons.copy - text = "Duplicate" - - @staticmethod - @exception_dialogs - def run(cs_name: str): - # prompt the user to give a name for the new calculation setup - new_name, ok = QtWidgets.QInputDialog.getText( - application.main_window, - f"Duplicate '{cs_name}'", - "Name of the duplicated calculation setup:" + " " * 10, - ) - - # return if the user cancels or gives no name - if not ok or not new_name: - return - - # throw error if the name is already present, and return - if new_name in bd.calculation_setups: - QtWidgets.QMessageBox.warning( - application.main_window, - "Not possible", - "A calculation setup with this name already exists.", - ) - return - - bd.calculation_setups[new_name] = bd.calculation_setups[cs_name].copy() - logger.info(f"Copied calculation setup {cs_name} as {new_name}") - CSOpen.run(new_name) diff --git a/activity_browser/app/actions/calculation_setup/cs_new.py b/activity_browser/app/actions/calculation_setup/cs_new.py deleted file mode 100644 index c5d3cedd7..000000000 --- a/activity_browser/app/actions/calculation_setup/cs_new.py +++ /dev/null @@ -1,91 +0,0 @@ -from loguru import logger - -from qtpy import QtWidgets - -import bw2data as bd - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.ui.icons import qicons - - - - -class CSNew(ABAction): - """ - Create a new Calculation Setup. - - This method prompts the user for a name for the new Calculation Setup (CS) if not provided. - It validates the name to ensure it is unique within the project and creates a new CS - with the specified functional units and impact categories. - - Args: - name (str, optional): The name of the new Calculation Setup. If not provided, the user is prompted. - functional_units (list[dict[tuple | int | bd.Node, float]], optional): A list of functional units to include in the CS. - impact_categories (list[tuple], optional): A list of impact categories to include in the CS. - - Returns: - None: Returns early if the user cancels, provides no name, or if the name already exists. - - Raises: - None: This method does not raise exceptions but logs errors and shows warnings for invalid inputs. - """ - - icon = qicons.add - text = "New calculation setup..." - - @staticmethod - @exception_dialogs - def run(name: str = None, - functional_units: list[dict[tuple | int | bd.Node, float]] = None, - impact_categories: list[tuple] = None - ): - - name = name or CSNew.get_cs_name() - - # return if the user cancels or gives no name - if not name: - return - - # throw error if the name is already present, and return - if name in bd.calculation_setups: - QtWidgets.QMessageBox.warning( - app.main_window, - "Not possible", - "A calculation setup with this name already exists.", - ) - return - - inv = functional_units or [] - for i, fu in enumerate(inv): - if not isinstance(fu, dict): - raise TypeError("Functional units must be a list of dictionaries.") - refreshed = {refresh_node(key).key: amount for key, amount in fu.items()} - inv[i] = refreshed - - ia = impact_categories or [] - - # instruct the CalculationSetupController to create a CS with the new name - bd.calculation_setups[name] = {"inv": inv, "ia": ia} - - logger.info(f"New calculation setup: {name}") - - app.actions.CSOpen.run(name) - - @staticmethod - def get_cs_name() -> str | None: - """ - Prompt the user for a name for the new calculation setup. - """ - # prompt the user to give a name for the new calculation setup - name, ok = QtWidgets.QInputDialog.getText( - app.main_window, - "Create new calculation setup", - "Name of new calculation setup:" + " " * 10, - ) - - # return if the user cancels or gives no name - if not ok or not name: - return None - return name diff --git a/activity_browser/app/actions/calculation_setup/cs_open.py b/activity_browser/app/actions/calculation_setup/cs_open.py deleted file mode 100644 index 688707b93..000000000 --- a/activity_browser/app/actions/calculation_setup/cs_open.py +++ /dev/null @@ -1,25 +0,0 @@ -from loguru import logger - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd - - -class CSOpen(ABAction): - text = "Open" - - @staticmethod - @exception_dialogs - def run(cs_names: str | list[str]): - if isinstance(cs_names, str): - cs_names = [cs_names] - - for cs_name in cs_names: - if cs_name not in bd.calculation_setups: - logger.warning(f"Calculation setup {cs_name} not found") - continue - - page = app.pages.CalculationSetupPage(cs_name) - central = app.main_window.centralWidget() - - central.addToGroup("LCA Setup", page) diff --git a/activity_browser/app/actions/calculation_setup/cs_rename.py b/activity_browser/app/actions/calculation_setup/cs_rename.py deleted file mode 100644 index 72ad71041..000000000 --- a/activity_browser/app/actions/calculation_setup/cs_rename.py +++ /dev/null @@ -1,49 +0,0 @@ -from loguru import logger - -from qtpy import QtWidgets - -from activity_browser.app import application, signals -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - - - -class CSRename(ABAction): - """ - ABAction to rename a calculation setup. Prompts the user for a new name. Returns if the user cancels, or if a CS - with the same name is already present within the project. If all is right, instructs the CalculationSetupController - to rename the CS. - """ - - icon = qicons.edit - text = "Rename" - - @staticmethod - @exception_dialogs - def run(cs_name: str, new_name: str = None): - # prompt the user to give a name for the new calculation setup - new_name, ok = QtWidgets.QInputDialog.getText( - application.main_window, - f"Rename '{cs_name}'", - "New name of this calculation setup:" + " " * 10, - ) - - # return if the user cancels or gives no name - if not ok or not new_name: - return - - # throw error if the name is already present, and return - if new_name in bd.calculation_setups: - QtWidgets.QMessageBox.warning( - application.main_window, - "Not possible", - "A calculation setup with this name already exists.", - ) - return - - # instruct the CalculationSetupController to rename the CS to the new name - bd.calculation_setups[new_name] = bd.calculation_setups[cs_name].copy() - del bd.calculation_setups[cs_name] - logger.info(f"Renamed calculation setup from {cs_name} to {new_name}") diff --git a/activity_browser/app/actions/database/database_delete.py b/activity_browser/app/actions/database/database_delete.py deleted file mode 100644 index 81af59e79..000000000 --- a/activity_browser/app/actions/database/database_delete.py +++ /dev/null @@ -1,96 +0,0 @@ -from typing import List - -from qtpy import QtCore, QtWidgets - -import bw2data as bd -from bw2data.parameters import Group -from bw2data.backends.proxies import ExchangeDataset, Exchanges - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - -class DatabaseDelete(ABAction): - """ - Deletes one or more databases from the project after user confirmation. - - This method performs the following steps: - - Displays a confirmation dialog to the user with the database name(s) and total record count. - - If the user confirms, deletes the database(s), their upstream exchanges, and associated parameters. - - Removes the database(s) from the project settings. - - Args: - db_names (List[str]): The name(s) of the database(s) to be deleted. - - Steps: - - Set the cursor to a waiting state while gathering data for large databases. - - Retrieve the record count for the specified database(s). - - Construct a warning message with the database name(s) and record count. - - Display a confirmation dialog to the user. - - If the user cancels, exit the method. - - Set the cursor to a waiting state while performing the deletion. - - Delete upstream exchanges associated with the database(s). - - Remove the database(s) from the Brightway2 project. - - Delete database parameters. - - Remove the database(s) from the project settings. - - Restore the cursor to its default state. - """ - - icon = qicons.delete - text = "Delete databases" - tool_tip = "Delete database(s) from the project" - - @staticmethod - @exception_dialogs - def run(db_names: List[str]): - # gathering data will take time for large databases - QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) - - # get the total record count from all databases - total_records = 0 - for db_name in db_names: - n_records = app.metadata.dataframe[app.metadata.dataframe["database"] == db_name].shape[0] - total_records += n_records - - # construct warning text - if len(db_names) == 1: - text = f"Are you sure you want to delete database '{db_names[0]}'?" - if total_records: - text += f" It contains {total_records} activities." - else: - text = f"Are you sure you want to delete {len(db_names)} databases?" - if total_records: - text += f" They contain {total_records} activities in total." - - # ask the user for confirmation - QtWidgets.QApplication.restoreOverrideCursor() - response = QtWidgets.QMessageBox.question( - app.main_window, build_title(db_names), text - ) - - # return if the user cancels - if response != QtWidgets.QMessageBox.Yes: - return - - # deleting data will take time for large databases - QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) - - for db_name in db_names: - # delete upstream exchanges - ExchangeDataset.delete().where(ExchangeDataset.input_database == db_name).execute() - - # instruct the DatabaseController to delete the database from the project. - del bd.databases[db_name] - - # delete database parameters - Group.delete().where(Group.name == db_name).execute() - - QtWidgets.QApplication.restoreOverrideCursor() - - -def build_title(db_names: List[str]) -> str: - """Build an appropriate title for the confirmation dialog.""" - if len(db_names) == 1: - return "Delete database?" - return "Delete databases?" diff --git a/activity_browser/app/actions/database/database_duplicate.py b/activity_browser/app/actions/database/database_duplicate.py deleted file mode 100644 index 3ca59654b..000000000 --- a/activity_browser/app/actions/database/database_duplicate.py +++ /dev/null @@ -1,102 +0,0 @@ -import copy - -from qtpy import QtWidgets - -import bw2data as bd -import bw_functional as bf - -from activity_browser.app import application -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons -from activity_browser.ui.core.threading import ABThread - -from .database_new import NewDatabaseDialog - - -class DatabaseDuplicate(ABAction): - """ - ABAction to duplicate a database. Asks the user to provide a new name for the database, and returns when the name is - already in use by an existing database. Then it shows a progress dialogue which will construct a new thread in which - the database duplication will take place. This thread instructs the DatabaseController to duplicate the selected - database with the chosen name. - """ - - icon = qicons.duplicate_database - text = "Duplicate database..." - tool_tip = "Make a duplicate of this database" - - @staticmethod - @exception_dialogs - def run(db_name: str): - assert db_name in bd.databases - backend = bd.databases[db_name].get("backend", "undefined") - - if backend not in ["sqlite", "functional_sqlite"]: - QtWidgets.QMessageBox.information( - application.main_window, - "Not possible", - f"Unsupported database backend {backend}", - ) - return - - name, backend, ok = NewDatabaseDialog.get_new_database_data(window_title="Duplicate database", backend=backend) - - if not name or not ok: - return - - if name in bd.databases: - QtWidgets.QMessageBox.information( - application.main_window, - "Not possible", - "A database with this name already exists.", - ) - return - - DuplicateDatabaseDialog(db_name, name, backend, application.main_window) - - -class DuplicateDatabaseDialog(QtWidgets.QProgressDialog): - def __init__(self, from_db: str, to_db: str, backend: str, parent=None): - super().__init__(parent=parent) - self.setWindowTitle("Duplicating database") - self.setLabelText( - f"Duplicating existing database {from_db} to new database {to_db}:" - ) - self.setModal(True) - self.setRange(0, 0) - - self.dup_thread = DuplicateDatabaseThread(application) - self.dup_thread.finished.connect(self.thread_finished) - - self.show() - - self.dup_thread.start(from_db, to_db, backend) - - def thread_finished(self) -> None: - self.dup_thread.exit(0) - self.setMaximum(1) - self.setValue(1) - - -class DuplicateDatabaseThread(ABThread): - - def run_safely(self, copy_from, copy_to, backend): - database = bd.Database(copy_from) - - data = database.load() - data = database.relabel_data(data, copy_from, copy_to) - - new_database = bd.Database(copy_to, backend=backend) - - metadata = copy.copy(database.metadata) - metadata["format"] = f"Copied from '{copy_from}'" - metadata["backend"] = backend - new_database.register(write_empty=False, **metadata) - - if database.backend == "sqlite" and backend == "functional_sqlite": - data = bf.convert_sqlite_to_functional_sqlite(data) - elif database.backend == "functional_sqlite" and backend == "sqlite": - data = bf.convert_functional_sqlite_to_sqlite(data) - - new_database.write(data, searchable=metadata.get("searchable")) - return new_database diff --git a/activity_browser/app/actions/database/database_explorer_open.py b/activity_browser/app/actions/database/database_explorer_open.py deleted file mode 100644 index 219c0d32e..000000000 --- a/activity_browser/app/actions/database/database_explorer_open.py +++ /dev/null @@ -1,21 +0,0 @@ -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - -class DatabaseExplorerOpen(ABAction): - """ - ABAction to delete a database from the project. Asks the user for confirmation. If confirmed, instructs the - DatabaseController to delete the database in question. - """ - - icon = qicons.delete - text = "Explore database" - tool_tip = "Delete this database from the project" - - @staticmethod - @exception_dialogs - def run(db_name: str): - from activity_browser.app.panes import DatabaseExplorerPane - db_explorer = DatabaseExplorerPane(db_name, app.main_window) - db_explorer.show() diff --git a/activity_browser/app/actions/database/database_export_bw2package.py b/activity_browser/app/actions/database/database_export_bw2package.py deleted file mode 100644 index db5fdd166..000000000 --- a/activity_browser/app/actions/database/database_export_bw2package.py +++ /dev/null @@ -1,99 +0,0 @@ -from loguru import logger -from typing import List - -from qtpy import QtWidgets - -from activity_browser.app import application, dialogs -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui import widgets -from activity_browser.bwutils import exporters -from activity_browser.ui.core import threading - - -class DatabaseExportBW2Package(ABAction): - """ - ABAction to export database(s) to BW2Package format (.bw2package). - """ - - # icon = icons.qicons.export_db - text = "Export to .bw2package" - tool_tip = "Export database(s) to BW2Package format" - - @classmethod - @exception_dialogs - def run(cls, db_names: List[str] = None): - if db_names is None: - import bw2data as bd - dialog = dialogs.DatabaseSelectDialog( - parent=application.main_window, - databases=sorted(bd.databases), - title="Select databases to export to BW2Package" - ) - if dialog.exec_() == QtWidgets.QDialog.Accepted: - db_names = dialog.get_selected_databases() - else: - return - - # Get export directory or file from user - if len(db_names) == 1: - # Single database - suggest a filename - suggested_name = f"{db_names[0]}.bw2package" - path, _ = QtWidgets.QFileDialog.getSaveFileName( - parent=application.main_window, - caption=f'Export database "{db_names[0]}" to BW2Package', - directory=suggested_name, - filter='Brightway2 Database Package (*.bw2package);; All files (*.*)' - ) - else: - # Multiple databases - ask for directory - path = QtWidgets.QFileDialog.getExistingDirectory( - parent=application.main_window, - caption=f'Select directory to export {len(db_names)} databases', - ) - - if not path: - return - - # Show export dialog - context = { - "db_names": db_names, - "path": path, - } - export_dialog = ExportBW2PackageSetup( - parent=application.main_window, - title="Export to BW2Package", - context=context - ) - export_dialog.show() - - -class ExportBW2PackageSetup(widgets.ABWizard): - """Wizard for exporting databases to BW2Package format.""" - - class ExportPage(widgets.ABThreadedWizardPage): - """Wizard page to export the selected database(s) to BW2Package.""" - title = "Exporting Database(s)" - subtitle = "Exporting database(s) to .bw2package file(s)" - - class Thread(threading.ABThread): - """Thread to handle the export process.""" - - def run_safely(self, db_names: List[str], path: str): - """Export the database(s) to BW2Package.""" - for db_name in db_names: - try: - success = exporters.store_database_as_package(db_name, path) - if success: - logger.info(f"Successfully exported database '{db_name}' to BW2Package") - else: - logger.error(f"Failed to export database '{db_name}'") - raise RuntimeError(f"Database '{db_name}' not found") - except Exception as e: - logger.error(f"Failed to export database '{db_name}': {e}") - raise - - def initializePage(self, context: dict): - """Start the export thread.""" - self.thread.start(context["db_names"], context["path"]) - - pages = [ExportPage] diff --git a/activity_browser/app/actions/database/database_export_excel.py b/activity_browser/app/actions/database/database_export_excel.py deleted file mode 100644 index 319c83e90..000000000 --- a/activity_browser/app/actions/database/database_export_excel.py +++ /dev/null @@ -1,95 +0,0 @@ -from loguru import logger -from typing import List - -from qtpy import QtWidgets - -from activity_browser.app import application, dialogs -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui import widgets -from activity_browser.bwutils import exporters -from activity_browser.ui.core import threading - - -class DatabaseExportExcel(ABAction): - """ - ABAction to export database(s) to Excel format (.xlsx). - """ - - icon = application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) - text = "Export to Excel (.xlsx)" - tool_tip = "Export database(s) to Excel format" - - @classmethod - @exception_dialogs - def run(cls, db_names: List[str] = None): - if db_names is None: - import bw2data as bd - dialog = dialogs.DatabaseSelectDialog( - parent=application.main_window, - databases=sorted(bd.databases), - title="Select databases to export to Excel" - ) - if dialog.exec_() == QtWidgets.QDialog.Accepted: - db_names = dialog.get_selected_databases() - else: - return - - # Get export directory or file from user - if len(db_names) == 1: - # Single database - suggest a filename - suggested_name = f"lci-{db_names[0]}.xlsx" - path, _ = QtWidgets.QFileDialog.getSaveFileName( - parent=application.main_window, - caption=f'Export database "{db_names[0]}" to Excel', - directory=suggested_name, - filter='Excel spreadsheet (*.xlsx);; All files (*.*)' - ) - else: - # Multiple databases - ask for directory - path = QtWidgets.QFileDialog.getExistingDirectory( - parent=application.main_window, - caption=f'Select directory to export {len(db_names)} databases', - ) - - if not path: - return - - # Show export dialog - context = { - "db_names": db_names, - "path": path, - } - export_dialog = ExportExcelSetup( - parent=application.main_window, - title="Export to Excel", - context=context - ) - export_dialog.show() - - -class ExportExcelSetup(widgets.ABWizard): - """Wizard for exporting databases to Excel format.""" - - class ExportPage(widgets.ABThreadedWizardPage): - """Wizard page to export the selected database(s) to Excel.""" - title = "Exporting Database(s)" - subtitle = "Exporting database(s) to Excel file(s)" - - class Thread(threading.ABThread): - """Thread to handle the export process.""" - - def run_safely(self, db_names: List[str], path: str): - """Export the database(s) to Excel.""" - for db_name in db_names: - try: - exporters.write_lci_excel(db_name, path) - logger.info(f"Successfully exported database '{db_name}' to Excel") - except Exception as e: - logger.error(f"Failed to export database '{db_name}': {e}") - raise - - def initializePage(self, context: dict): - """Start the export thread.""" - self.thread.start(context["db_names"], context["path"]) - - pages = [ExportPage] diff --git a/activity_browser/app/actions/database/database_import_from_ecoinvent.py b/activity_browser/app/actions/database/database_import_from_ecoinvent.py deleted file mode 100644 index 10e89eb5e..000000000 --- a/activity_browser/app/actions/database/database_import_from_ecoinvent.py +++ /dev/null @@ -1,477 +0,0 @@ -import re -import os -from loguru import logger -from copy import deepcopy - -import requests - -import ecoinvent_interface as ei -import bw2data as bd -import bw2io as bi - -from qtpy import QtWidgets, QtCore -from qtpy.QtCore import Signal, SignalInstance - -from activity_browser.app import application, signals -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.mod.bw2io.migrations import ab_create_core_migrations -from activity_browser.ui.core import threading - - - - -class DatabaseImportFromEcoinvent(ABAction): - """ - Launches the `EiWizard` dialog for importing a database from ecoinvent. - - This method creates an instance of the `EiWizard` class, passing the - main application window as its parent, and executes the wizard dialog. - - Raises: - Any exceptions encountered during the execution of the wizard - are handled by the `exception_dialogs` decorator. - """ - - icon = icons.qicons.import_db - text = "Import database from ecoinvent" - tool_tip = "Import database from ecoinvent" - - @staticmethod - @exception_dialogs - def run(): - setup = EiWizard(application.main_window) - setup.setWindowTitle("Import from ecoinvent") - setup.exec_() - - -class EiWizard(widgets.ABWizard): - """Wizard for importing database from ecoinvent""" - - class RemoteOrLocalPage(widgets.ABWizardPage): - """Wizard page to choose between remote or local ecoinvent release""" - title = "Import from ecoinvent" - subtitle = "Choose whether to import from a remote or local ecoinvent release." - buttonLayout = ["Stretch", "CancelButton", "NextButton"] - - def __init__(self, parent=None): - super().__init__(parent) - - self.remote_button = QtWidgets.QRadioButton("Remote") - self.local_button = QtWidgets.QRadioButton("Local") - self.remote_button.setChecked(True) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.remote_button) - layout.addWidget(self.local_button) - self.setLayout(layout) - - def nextPage(self): - """Determine the next page based on the user's selection""" - if self.local_button.isChecked(): - return EiWizard.LocalSelectPage - else: - return EiWizard.LoginPage - - class LocalSelectPage(widgets.ABWizardPage): - """Wizard page to select a local ecoinvent .7z file""" - title = "Import from ecoinvent" - subtitle = "Select local ecoinvent .7z." - buttonLayout = ["Stretch", "CancelButton", "BackButton", "NextButton"] - - def __init__(self, parent=None): - super().__init__(parent) - - self.file_selector = widgets.ABFileSelector(filter="*.7z") - self.file_selector.textChanged.connect(lambda: self.completeChanged.emit()) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.file_selector) - self.setLayout(layout) - - def finalize(self, context: dict): - """Store the selected file path in the context""" - path = self.file_selector.text() - context["ei_filepath"] = path - # Try to extract version and model from the filename - if version:= re.search(r'ecoinvent ([\d.]+)', path): - context["version"] = version.group(1) - if model:= re.search(r'ecoinvent [\d.]+_([^_]+)', path): - context["model"] = model.group(1) - - def isComplete(self): - """Check if a file has been selected""" - return bool(self.file_selector.text()) - - def nextPage(self): - """Proceed to the BiosphereSetupPage""" - return EiWizard.BiosphereSetupPage - - class LoginPage(widgets.ABWizardPage): - """Wizard page to login with ecoinvent credentials""" - title = "Login" - subtitle = "Login with your ecoinvent credentials to authorize the download" - buttonLayout = ["Stretch", "CancelButton", "BackButton", "NextButton"] - - def __init__(self, parent=None): - super().__init__(parent) - - self.release = None - - self.username = QtWidgets.QLineEdit() - self.username.setPlaceholderText('ecoinvent username') - - self.password = QtWidgets.QLineEdit() - self.password.setPlaceholderText('ecoinvent password') - self.password.setEchoMode(QtWidgets.QLineEdit.Password) - - self.message = QtWidgets.QLabel() - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.username) - layout.addWidget(self.password) - layout.addWidget(self.message) - - self.setLayout(layout) - - def initializePage(self, context: dict): - """Initialize the page with stored username and password""" - settings = ei.Settings() - self.username.setText(settings.username) - self.password.setText(settings.password) - - def validatePage(self): - """Validate the login credentials by attempting to list ecoinvent versions""" - QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) - try: - settings = ei.Settings(username=self.username.text(), password=self.password.text()) - self.release = ei.EcoinventRelease(settings) - self.release.list_versions() - except requests.exceptions.HTTPError as e: - QtWidgets.QApplication.restoreOverrideCursor() - if e.response.status_code == 401: - self.message.setText("Invalid username and/or password, please try again.") - return False - else: - self.message.setText("Unknown connection error, try again later.") - raise e - ei.permanent_setting("username", self.username.text()) - ei.permanent_setting("password", self.password.text()) - QtWidgets.QApplication.restoreOverrideCursor() - return True - - def finalize(self, context: dict): - """Store the release object in the context""" - context["release"] = self.release - - def nextPage(self): - """Proceed to the EcoinventVersionPage""" - return EiWizard.EcoinventVersionPage - - class EcoinventVersionPage(widgets.ABWizardPage): - """Wizard page to choose ecoinvent version and system model""" - title = "Choose version" - subtitle = "Choose ecoinvent version and system model" - buttonLayout = ["Stretch", "CancelButton", "BackButton", "NextButton"] - - def __init__(self, parent=None): - super().__init__(parent) - - self.versions = QtWidgets.QComboBox() - self.models = QtWidgets.QComboBox() - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.versions) - layout.addWidget(self.models) - - self.setLayout(layout) - - def initializePage(self, context: dict): - """Initialize the page with available versions and models""" - self.release = context["release"] - self.versions.currentTextChanged.connect(self.collect_models) - self.versions.addItems(self.release.list_versions()) - - def finalize(self, context: dict): - """Store the selected version and model in the context""" - context["version"] = self.versions.currentText() - context["model"] = self.models.currentText() - - def nextPage(self): - """Proceed to the EcoinventDownloadPage""" - return EiWizard.EcoinventDownloadPage - - def collect_models(self, version: str): - """Collect and display system models for the selected version""" - QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) - self.models.clear() - self.models.addItems(self.release.list_system_models(version)) - QtWidgets.QApplication.restoreOverrideCursor() - - class EcoinventDownloadPage(widgets.ABThreadedWizardPage): - """Wizard page to download the selected ecoinvent release""" - title = "Download ecoinvent" - subtitle = "Downloading the selected ecoinvent release" - buttonLayout = ["Stretch", "NextButton"] - - class Thread(threading.ABThread): - """Thread to handle the download process""" - download_ready: SignalInstance = Signal(str) - - def run_safely(self, release: ei.release, version: str, model: str): - """Download the ecoinvent release""" - path = release.get_release( - version=version, - system_model=model, - release_type=ei.ReleaseType.ecospold, - extract=False, - fix_version=False - ) - path = str(path) - if not path.endswith(".7z") and os.path.exists(path + ".7z"): - path = path + ".7z" - self.download_ready.emit(path) - - def __init__(self, parent=None): - super().__init__(parent) - self.ei_filepath = None - - def initializePage(self, context: dict): - """Start the download thread""" - self.thread.start(context["release"], context["version"], context["model"]) - self.thread.download_ready.connect(self.download_ready) - - def download_ready(self, filepath: str): - """Handle the completion of the download""" - self.ei_filepath = filepath - - def finalize(self, context: dict): - """Store the downloaded file path in the context""" - context["ei_filepath"] = self.ei_filepath - - def nextPage(self): - """Proceed to the BiosphereSetupPage""" - return EiWizard.BiosphereSetupPage - - class BiosphereSetupPage(widgets.ABWizardPage): - """Wizard page to choose biosphere setup options""" - title = "Biosphere setup" - subtitle = "Choose whether to import the biosphere database or connect to an existing one" - buttonLayout = ["Stretch", "CancelButton", "CommitButton"] - - def __init__(self, parent=None): - super().__init__(parent) - - self.biosphere_choice = widgets.ABRadioButtonCollapser(self) - self.biosphere_choice.buttonClicked.connect(lambda: self.completeChanged.emit()) - - self.biosphere_choice.addOption( - name="existing", - label="Link to an existing biosphere", - w=widgets.ABComboBox.get_database_combobox() - ) - - self.biosphere_choice.addOption( - name="import", - label="Import included biosphere", - w=widgets.DatabaseNameEdit(database_preset="biosphere") - ) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.biosphere_choice) - self.setLayout(layout) - - def isComplete(self): - """Check if a biosphere option has been selected""" - return self.biosphere_choice.currentOption() is not None - - def initializePage(self, context: dict): - """Start the biosphere installation thread""" - if "version" in context: - self.biosphere_choice.view("import").setText(f"biosphere-{context['version']}") - - def finalize(self, context: dict): - """Store the selected biosphere option in the context""" - if self.biosphere_choice.currentOption() == "existing": - context["biosphere_name"] = self.biosphere_choice.view("existing").currentText() - else: - context["biosphere_name"] = self.biosphere_choice.view("import").text() - - def nextPage(self): - """Proceed to the appropriate next page based on the biosphere choice""" - if self.biosphere_choice.currentOption() == "existing": - return EiWizard.EcoinventSetupPage - else: - return EiWizard.BiosphereInstallPage - - class BiosphereInstallPage(widgets.ABThreadedWizardPage): - """Wizard page to install the biosphere database""" - title = "Installing biosphere database" - subtitle = "Installing bundled biosphere database into the project" - buttonLayout = ["Stretch", "NextButton"] - - class Thread(threading.ABThread): - """Thread to handle the biosphere installation process""" - def run_safely(self, ei_filepath: str, biosphere_name: str): - """Install the biosphere database""" - importer = Ecoinvent7zImporter(ei_filepath) - importer.install_biosphere(biosphere_name) - - def initializePage(self, context: dict): - """Start the biosphere installation thread""" - self.thread.start(context["ei_filepath"], context["biosphere_name"]) - - def nextPage(self): - """Proceed to the MethodsSetupPage""" - return EiWizard.MethodsSetupPage - - class MethodsSetupPage(widgets.ABWizardPage): - """Wizard page to choose methods setup options""" - title = "Methods setup" - subtitle = "Choose whether to import methods from ecoinvent or from file" - buttonLayout = ["Stretch", "CommitButton"] - - def __init__(self, parent=None): - super().__init__(parent) - - self.methods_choice = widgets.ABRadioButtonCollapser(self) - self.methods_choice.buttonClicked.connect(lambda: self.completeChanged.emit()) - - self.methods_choice.addOption( - name="remote", - label="Download methods from ecoinvent", - w=QtWidgets.QWidget() - ) - - self.methods_choice.addOption( - name="local", - label="Import methods from file", - w=widgets.ABFileSelector(filter="*.xlsx") - ) - - self.methods_choice.addOption( - name="skip", - label="Don't import methods", - w=QtWidgets.QWidget() - ) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.methods_choice) - - self.setLayout(layout) - - def finalize(self, context: dict): - """Store the selected methods option in the context""" - if self.methods_choice.currentOption() == "remote": - file = ei.get_excel_lcia_file_for_version(context["release"], context["version"]) - context["methods_filepath"] = str(file) - if self.methods_choice.currentOption() == "local": - context["methods_filepath"] = self.methods_choice.view("local").text() - - def isComplete(self): - """Check if a methods option has been selected""" - return self.methods_choice.currentOption() is not None - - def nextPage(self): - """Proceed to the appropriate next page based on the methods choice""" - if self.methods_choice.currentOption() == "remote" or self.methods_choice.currentOption() == "local": - return EiWizard.MethodsInstallPage - else: - return EiWizard.EcoinventSetupPage - - class MethodsInstallPage(widgets.ABThreadedWizardPage): - """Wizard page to install the selected methods""" - title = "Installing methods" - subtitle = "Installing selected methods and linking to the biosphere" - buttonLayout = ["Stretch", "NextButton"] - - class Thread(threading.ABThread): - """Thread to handle the methods installation process""" - def run_safely(self, methods_filepath: str, biosphere_name: str): - """Install the methods and link to the biosphere""" - importer = EcoinventLCIAImporter.setup_with_ei_excel(methods_filepath) - importer.set_biosphere(biosphere_name) - importer.apply_strategies() - - signals.method.blockSignals(True) - signals.meta.blockSignals(True) - - old = bd.methods.deserialize() - importer.write_methods(overwrite=True) - - signals.method.blockSignals(False) - signals.meta.blockSignals(False) - - signals.meta.methods_changed.emit(deepcopy(bd.methods.data), old) - - def initializePage(self, context: dict): - """Start the methods installation thread""" - self.thread.start(context["methods_filepath"], context["biosphere_name"]) - - def nextPage(self): - """Proceed to the EcoinventSetupPage""" - return EiWizard.EcoinventSetupPage - - class EcoinventSetupPage(widgets.ABWizardPage): - """Wizard page to set up the ecoinvent database""" - title = "Ecoinvent setup" - subtitle = "Choose name for ecoinvent database" - buttonLayout = ["Stretch", "CancelButton", "CommitButton"] - - def __init__(self, parent=None): - super().__init__(parent) - self.database_name = widgets.DatabaseNameEdit(database_preset="ecoinvent") - self.database_name.textChanged.connect(self.completeChanged) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.database_name) - self.setLayout(layout) - - def isComplete(self): - """Check if a database name has been entered""" - return bool(self.database_name.text()) - - def initializePage(self, context: dict): - """Start the biosphere installation thread""" - if "version" in context and "model" in context: - self.database_name.setText(f"ecoinvent-{context['version']}-{context['model']}") - - def finalize(self, context: dict): - """Store the database name in the context""" - context["database_name"] = self.database_name.text() - - def nextPage(self): - """Proceed to the EcoinventInstallPage""" - return EiWizard.EcoinventInstallPage - - class EcoinventInstallPage(widgets.ABThreadedWizardPage): - """Wizard page to install the ecoinvent database""" - title = "Installing ecoinvent" - subtitle = "Installing ecoinvent database into the project" - buttonLayout = ["Stretch", "FinishButton"] - - class Thread(threading.ABThread): - """Thread to handle the ecoinvent installation process""" - def run_safely(self, ei_filepath: str, database_name: str, biosphere_name: str): - """Install the ecoinvent database""" - importer = Ecoinvent7zImporter(ei_filepath) - importer.install_ecoinvent(database_name, biosphere_name) - - # Run migrations after installation - if len(bi.migrations) == 0: - ab_create_core_migrations() - - def initializePage(self, context: dict): - """Start the ecoinvent installation thread""" - self.thread.start( - context["ei_filepath"], - context["database_name"], - context["biosphere_name"] - ) - - pages = [ - RemoteOrLocalPage, LocalSelectPage, LoginPage, EcoinventVersionPage, EcoinventDownloadPage, BiosphereSetupPage, - BiosphereInstallPage, MethodsSetupPage, MethodsInstallPage, EcoinventSetupPage, EcoinventInstallPage - ] diff --git a/activity_browser/app/actions/database/database_importer_bw2package.py b/activity_browser/app/actions/database/database_importer_bw2package.py deleted file mode 100644 index d42cbaa28..000000000 --- a/activity_browser/app/actions/database/database_importer_bw2package.py +++ /dev/null @@ -1,90 +0,0 @@ -import os -from loguru import logger - -from qtpy import QtWidgets - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui import icons, widgets -from activity_browser.bwutils.importers import ABPackage -from activity_browser.ui.core import threading - - - - -class DatabaseImporterBW2Package(ABAction): - """ABAction to open the DatabaseImportWizard""" - - icon = icons.qicons.import_db - text = "Import database from .bw2package" - tool_tip = "Import database from .bw2package" - - @classmethod - @exception_dialogs - def run(cls): - # get the path from the user - path, _ = QtWidgets.QFileDialog.getOpenFileName( - parent=app.main_window, - caption='Choose .bw2package to import', - filter='Brightway2 Database Package (*.bw2package);; All files (*.*)' - ) - if not path: - return - - # a bit of pathname magic to get a suggested database name - context = { - "path": path, - "database_name": os.path.basename(path).split('.bw2package')[0] - } - - # show the import setup dialog - import_dialog = ImportSetup(parent=app.main_window, title="Import Database", context=context) - import_dialog.exec_() - - -class ImportSetup(widgets.ABWizard): - class DatabaseName(widgets.ABWizardPage): - title = "Database Name" - subtitle = "Enter the name of the database you wish to create" - - def __init__(self, parent=None): - super().__init__(parent) - self.db_name_edit = widgets.DatabaseNameEdit( - label="Set database name:", - database_preset="", - ) - self.db_name_edit.textChanged.connect(self.completeChanged) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.db_name_edit) - self.setLayout(layout) - - def isComplete(self): - return bool(self.db_name_edit.text()) - - def initializePage(self, context: dict): - self.db_name_edit.setText(context["database_name"]) - - def finalize(self, context: dict): - context["database_name"] = self.db_name_edit.text() - - def nextPage(self): - return ImportSetup.InstallPage - - class InstallPage(widgets.ABThreadedWizardPage): - """Wizard page to install the selected bw2package""" - title = "Importing Database" - subtitle = "Importing database from .bw2package file" - - class Thread(threading.ABThread): - """Thread to handle the install process""" - def run_safely(self, path: str, db_name: str): - """Download the ecoinvent release""" - ABPackage.import_file(path, rename=db_name) - - def initializePage(self, context: dict): - """Start the download thread""" - self.thread.start(context["path"], context["database_name"]) - - pages = [DatabaseName, InstallPage] - diff --git a/activity_browser/app/actions/database/database_importer_excel.py b/activity_browser/app/actions/database/database_importer_excel.py deleted file mode 100644 index 6604add2b..000000000 --- a/activity_browser/app/actions/database/database_importer_excel.py +++ /dev/null @@ -1,211 +0,0 @@ -from loguru import logger - -from qtpy import QtWidgets -from qtpy.QtCore import Signal, SignalInstance - -import bw2data as bd - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui import widgets -from activity_browser.bwutils.importers import ABExcelImporter -from activity_browser.ui.core import threading - - - - -class DatabaseImporterExcel(ABAction): - """ABAction to open the DatabaseImportWizard""" - - text = "Import database from brightway excel format" - tool_tip = "Import database from brightway excel format" - - @classmethod - @exception_dialogs - def run(cls): - # get the path from the user - path, _ = QtWidgets.QFileDialog.getOpenFileName( - parent=app.main_window, - caption='Choose brightway excel database to import', - filter='excel spreadsheet (*.xlsx);; All files (*.*)' - ) - if not path: - return - - import_setup = ImportSetup(title="Import from Excel", context={"path": path}) - import_setup.exec_() - - -class ImportSetup(widgets.ABWizard): - - def customButtonOne(self): - def callback(): - importer : ABExcelImporter = self.context.get("importer") - if not importer: - return - dialog = app.dialogs.ImportPreviewDialog(importer, parent=app.main_window) - dialog.exec_() - return "Data", callback - - class ExtractPage(widgets.ABThreadedWizardPage): - title = "Extracting Database" - subtitle = "Extracting database from excel file" - buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "NextButton"] - customButton1Text = "Show extracted data" - - class Thread(threading.ABThread): - loaded: SignalInstance = Signal(object) - - def run_safely(self, path: str): - importer = ABExcelImporter(path) - importer.apply_basic_strategies() - self.loaded.emit(importer) - - def initializePage(self, context: dict): - """Start the download thread""" - self.thread.start(context["path"]) - self.thread.loaded.connect(self.thread_finished) - - button = self.wizard().button(QtWidgets.QWizard.CustomButton1) - button.setEnabled(False) - - def thread_finished(self, importer: ABExcelImporter): - logger.debug("Extraction thread finished") - self.context()["importer"] = importer - - button = self.wizard().button(QtWidgets.QWizard.CustomButton1) - button.setEnabled(True) - - def nextPage(self) -> type[QtWidgets.QWizardPage] | None: - return ImportSetup.DatabaseName - - class DatabaseName(widgets.ABWizardPage): - title = "Database Name" - subtitle = "Enter the name of the database you wish to create" - buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "NextButton"] - - def __init__(self, parent=None): - super().__init__(parent) - self.db_name_edit = widgets.DatabaseNameEdit( - label="Set database name:", - database_preset="", - ) - self.db_name_edit.textChanged.connect(self.completeChanged) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.db_name_edit) - self.setLayout(layout) - - def isComplete(self): - return bool(self.db_name_edit.text()) - - def initializePage(self, context: dict): - self.db_name_edit.setText(context["importer"].db_name) - self.wizard().setButtonText(QtWidgets.QWizard.WizardButton.NextButton, "Apply") - - def finalize(self, context: dict): - importer = context["importer"] - importer.apply_db_name(self.db_name_edit.text()) - - context["database_name"] = self.db_name_edit.text() - - def nextPage(self): - return ImportSetup.DatabaseLink - - class DatabaseLink(widgets.ABWizardPage): - title = "Link Databases" - subtitle = "Link the imported database to existing databases" - buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "NextButton"] - - def __init__(self, parent=None): - super().__init__(parent) - layout = QtWidgets.QGridLayout(self) - self.setLayout(layout) - self.link_dict_edit = {} - - def isComplete(self): - return True - - def initializePage(self, context: dict): - # fetch the unlinked databases from the importer - importer = context["importer"] - link_dbs = set([exc["database"] for exc in importer.unlinked]) - layout = self.layout() - - for i, db in enumerate(link_dbs): - if db == importer.db_name: - continue - - layout.addWidget(QtWidgets.QLabel(db), i, 0) - - drop_down = QtWidgets.QComboBox(self) - drop_down.addItems(sorted(bd.databases)) - - if db in bd.databases: - drop_down.setCurrentText(db) - - layout.addWidget(drop_down, i, 1) - - self.link_dict_edit[db] = drop_down - - def finalize(self, context: dict): - importer = context["importer"] - importer.apply_linking({k: v.currentText() for k, v in self.link_dict_edit.items()}) - - context["linking_dict"] = {k: v.currentText() for k, v in self.link_dict_edit.items()} - - def nextPage(self): - return ImportSetup.ConfirmPage - - class ConfirmPage(widgets.ABWizardPage): - title = "Database Overview" - subtitle = "Confirming and installing the database" - buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "CommitButton"] - - def __init__(self, parent=None): - super().__init__(parent) - layout = QtWidgets.QGridLayout(self) - self.setLayout(layout) - - def isComplete(self): - return True - - def initializePage(self, context: dict): - importer = context["importer"] - layout = self.layout() - row = 0 - for key, value in { - "Database Name": importer.db_name, - "Number of Activities": len(importer.data), - "Number of Exchanges": sum(len(act.get("exchanges", [])) for act in importer.data), - "Number of Unlinked Exchanges": len(list(importer.unlinked)), - }.items(): - layout.addWidget(QtWidgets.QLabel(f"{key}:"), row, 0) - layout.addWidget(QtWidgets.QLabel(str(value)), row, 1) - row += 1 - - def nextPage(self): - return ImportSetup.InstallPage - - class InstallPage(widgets.ABThreadedWizardPage): - title = "Importing Database" - subtitle = "Importing database from .xlsx file" - - class Thread(threading.ABThread): - """Thread to handle the install process""" - - def run_safely(self, importer: ABExcelImporter, database_name: str, linking_dict: dict): - """Download the ecoinvent release""" - importer.write_database() - - def initializePage(self, context: dict): - """Start the download thread""" - importer = context["importer"] - database_name = context["database_name"] - linking_dict = context.get("linking_dict", {}) - - self.thread.start(importer, database_name, linking_dict) - - pages = [ExtractPage, DatabaseName, DatabaseLink, ConfirmPage, InstallPage] - - diff --git a/activity_browser/app/actions/database/database_new.py b/activity_browser/app/actions/database/database_new.py deleted file mode 100644 index 2933e9d45..000000000 --- a/activity_browser/app/actions/database/database_new.py +++ /dev/null @@ -1,111 +0,0 @@ -from qtpy import QtWidgets, QtCore - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - -from .database_open import DatabaseOpen - - -class DatabaseNew(ABAction): - """ - Executes the process of creating a new database. - - This method retrieves the database name and backend type from the `NewDatabaseDialog`. - It validates the input to ensure the name is not empty and does not already exist. - If the input is valid, it creates and registers a new database, then opens it. - - Steps: - - Open the `NewDatabaseDialog` to get the database name and backend type. - - Return if the dialog is canceled or the name is empty. - - Check if the database name already exists and show an error message if it does. - - Create a new database with the specified name and backend type. - - Register the database with default settings (not searchable, not read-only). - - Open the newly created database. - - Raises: - None - """ - icon = qicons.add - text = "New database..." - tool_tip = "Make a new database" - - @staticmethod - @exception_dialogs - def run(): - name, backend, ok = NewDatabaseDialog.get_new_database_data() - - if not ok or not name: - return - - if name in bd.databases: - QtWidgets.QMessageBox.information( - app.main_window, - "Not possible", - "A database with this name already exists.", - ) - return - - db = bd.Database(name, backend if backend else "functional_sqlite") - db.register(searchable=False, read_only=False) - - DatabaseOpen.run([name]) - - -class NewDatabaseDialog(QtWidgets.QDialog): - """ - A dialog for creating a new database. - """ - - def __init__(self, window_title="New Database", backend="functional_sqlite", parent=None): - super().__init__(parent) - self.setWindowTitle(window_title) - self.setModal(True) - - self.name_input = QtWidgets.QLineEdit(self) - self.name_input.setPlaceholderText("Enter database name") - self.name_input.textChanged.connect(self.validate) - - self.backend_dropdown = QtWidgets.QComboBox(self) - self.backend_dropdown.addItems(["functional_sqlite", "sqlite"]) - self.backend_dropdown.setCurrentText(backend) - - self.create_button = QtWidgets.QPushButton("Create", self) - self.create_button.setDisabled(True) - self.create_button.clicked.connect(self.accept) - - self.build_layout() - - @classmethod - def get_new_database_data(cls, window_title="New Database", backend="functional_sqlite") -> tuple[str, str, bool]: - """ - Opens a dialog to collect data for creating a new database. - - Returns: - tuple[str, str, bool]: A tuple containing: - - The name of the new database (str). - - The selected backend type (str). - - A boolean indicating whether the dialog was accepted (True) or canceled (False). - """ - dialog = cls(window_title, backend, app.main_window) - result = dialog.exec_() - - return dialog.name_input.text(), dialog.backend_dropdown.currentText(), result == QtWidgets.QDialog.Accepted - - - def build_layout(self): - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(self.name_input) - layout.addWidget(QtWidgets.QLabel("Select backend:", self)) - layout.addWidget(self.backend_dropdown) - layout.addWidget(self.create_button, alignment=QtCore.Qt.AlignRight) - self.setLayout(layout) - - def validate(self, text): - if text in bd.databases or not text: - self.create_button.setDisabled(True) - else: - self.create_button.setDisabled(False) - - diff --git a/activity_browser/app/actions/database/database_open.py b/activity_browser/app/actions/database/database_open.py deleted file mode 100644 index 3a8ef5623..000000000 --- a/activity_browser/app/actions/database/database_open.py +++ /dev/null @@ -1,64 +0,0 @@ -from qtpy.QtCore import Qt, QEventLoop - -from activity_browser import app -from activity_browser.ui import widgets -from activity_browser.app.actions.base import ABAction, exception_dialogs - - - - -class DatabaseOpen(ABAction): - text = "Open Database" - - @staticmethod - @exception_dialogs - def run(database_names: list[str]): - from activity_browser.app import panes - - sibling = DatabaseOpen.find_sibling() - - for db_name in database_names: - db_pane = panes.DatabaseProductsPane(app.main_window, db_name) - dock_widget = db_pane.getDockWidget(app.main_window) - dock_widget.resize(dock_widget.width(), app.main_window.height() // 2) - - app.main_window.addDockWidget(DatabaseOpen.get_area(), dock_widget) - - if sibling: - app.main_window.tabifyDockWidget(sibling, dock_widget) - - app.application.thread().eventDispatcher().processEvents(QEventLoop.ProcessEventsFlags.AllEvents) - dock_widget.raise_() - dock_widget.show() - else: - dock_widget.show() - app.main_window.resizeDocks( - [dock_widget], - [1000], - Qt.Vertical - ) - - @staticmethod - def find_sibling(): - """ - Find the dockwidget location where the database pane should be opened. - """ - from activity_browser.app import panes - - all_dws = app.main_window.findChildren(widgets.ABDockWidget) - databases_dw = app.main_window.findChild(widgets.ABDockWidget, "dockwidget-databases_pane") - - products_dws = [w for w in all_dws if - isinstance(w.widget(), panes.DatabaseProductsPane) and - app.main_window.dockWidgetArea(w) == app.main_window.dockWidgetArea(databases_dw) and - not w.visibleRegion().isNull() - ] - return products_dws[0] if products_dws else None - - @staticmethod - def get_area(): - """ - Find the dockwidget location where the database pane should be opened. - """ - databases_dw = app.main_window.findChild(widgets.ABDockWidget, "dockwidget-databases_pane") - return app.main_window.dockWidgetArea(databases_dw) diff --git a/activity_browser/app/actions/database/database_process.py b/activity_browser/app/actions/database/database_process.py deleted file mode 100644 index 0f5245829..000000000 --- a/activity_browser/app/actions/database/database_process.py +++ /dev/null @@ -1,19 +0,0 @@ -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - -class DatabaseProcess(ABAction): - """ - ... - """ - - icon = qicons.process - text = "Process database" - tool_tip = "Process database into datapackages" - - @staticmethod - @exception_dialogs - def run(db_name: str): - db = bd.Database(db_name) - db.process() diff --git a/activity_browser/app/actions/database/database_relink.py b/activity_browser/app/actions/database/database_relink.py deleted file mode 100644 index ba6325b92..000000000 --- a/activity_browser/app/actions/database/database_relink.py +++ /dev/null @@ -1,273 +0,0 @@ -from qtpy import QtCore, QtWidgets - -import bw2data as bd -from bw2data.backends import ExchangeDataset, sqlite3_lci_db - -from activity_browser.app import application, metadata -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - -class DatabaseRelink(ABAction): - """ - ABAction to relink the dependencies of a database. - """ - - icon = qicons.edit - text = "Relink the database" - tool_tip = "Relink the dependencies of this database" - - @staticmethod - @exception_dialogs - def run(db_name: str): - db_name = db_name - # get brightway database object - db = bd.Database(db_name) - - depends = ExchangeDataset.select(ExchangeDataset.input_database).where(ExchangeDataset.output_database == db_name) - depends = set([d.input_database for d in depends if d.input_database != db_name]) - - # find the dependencies of the database and construct a list of suitable candidates - options = [(depend, list(bd.databases)) for depend in depends] - - # construct a dialog in which the user chan choose which depending database to connect to which candidate - dialog = DatabaseLinkingDialog.relink_sqlite( - db_name, options, application.main_window - ) - - # return if the user cancels - if dialog.exec_() != DatabaseLinkingDialog.Accepted: - return - - linking_dict = {k: v for k, v in dialog.links.items() if k != v} - - if not linking_dict: - return - - relink_keys = DatabaseRelink.get_input_keys(db_name, list(linking_dict.keys())) - datasets = metadata.get_metadata(relink_keys, ["name", "product", "unit", "categories", "location"]) - - relink_key_map = {} - for ds in datasets.itertuples(): - key = ds.Index - database = linking_dict.get(key[0]) - match = metadata.match( - name=ds.name, - product=ds.product, - unit=ds.unit, - categories=ds.categories, - location=ds.location, - database=database, - ) - - if not len(match) == 1: - raise Exception(f"Could not uniquely relink exchange from {key} in database {database}") - - relink_key_map[key] = match.index[0] - - DatabaseRelink.set_input_keys(db_name, relink_key_map) - - QtWidgets.QMessageBox.information( - application.main_window, - "Database relinked", - f"Successfully relinked database '{db_name}'." - ) - - @staticmethod - def get_input_keys(output_db: str, db_list: list[str]) -> list[tuple[str, str]]: - return list( - ( - ExchangeDataset - .select(ExchangeDataset.input_database, ExchangeDataset.input_code) - .where( - (ExchangeDataset.output_database == output_db) & - (ExchangeDataset.input_database << db_list) - ) - ).tuples() - ) - - @staticmethod - def set_input_keys(output_db: str, key_map: dict[tuple[str, str], tuple[str, str]]) -> None: - with sqlite3_lci_db.db.atomic(): - for old_key, new_key in key_map.items(): - ExchangeDataset.update( - input_database=new_key[0], - input_code=new_key[1] - ).where( - (ExchangeDataset.output_database == output_db) & - (ExchangeDataset.input_database == old_key[0]) & - (ExchangeDataset.input_code == old_key[1]) - ).execute() - - -class DatabaseLinkingDialog(QtWidgets.QDialog): - """Display all of the possible links in a single dialog for the user. - - Allow users to select alternate database links.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Database linking") - - self.db_label = QtWidgets.QLabel() - self.label_choices = [] - self.grid_box = QtWidgets.QGroupBox("Database links:") - self.grid = QtWidgets.QGridLayout() - self.grid_box.setLayout(self.grid) - - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.db_label) - layout.addWidget(self.grid_box) - layout.addWidget(self.buttons) - self.setLayout(layout) - - @property - def relink(self) -> dict: - """Returns a dictionary of str -> str key/values, showing which keys - should be linked to which values. - - Only returns key/value pairs if they differ. - """ - return { - label.text(): combo.currentText() - for label, combo in self.label_choices - if label.text() != combo.currentText() - } - - @property - def links(self) -> dict: - """Returns a dictionary of str -> str key/values, showing which keys - should be linked to which values. - """ - return { - label.text(): combo.currentText() for label, combo in self.label_choices - } - - @classmethod - def construct_dialog( - cls, - label: str, - options: list, - parent: QtWidgets.QWidget = None, - ) -> "DatabaseLinkingDialog": - obj = cls(parent) - obj.db_label.setText(label) - # Start at 1 because row 0 is taken up by the db_label - for i, item in enumerate(options): - label = QtWidgets.QLabel(item[0]) - combo = QtWidgets.QComboBox() - combo.addItems(item[1]) - combo.setCurrentText(item[0]) - obj.label_choices.append((label, combo)) - obj.grid.addWidget(label, i, 0, 1, 2) - obj.grid.addWidget(combo, i, 2, 1, 2) - obj.updateGeometry() - return obj - - @classmethod - def relink_sqlite( - cls, db: str, options: list, parent=None - ) -> "DatabaseLinkingDialog": - label = "Relinking exchanges from database '{}'.".format(db) - return cls.construct_dialog(label, options, parent) - - @classmethod - def relink_bw2package( - cls, options: list, parent=None - ) -> "DatabaseLinkingDialog": - label = ( - "Some database(s) could not be found in the current project," - " attempt to relink the exchanges to a different database?" - ) - return cls.construct_dialog(label, options, parent) - - @classmethod - def relink_excel( - cls, options: list, parent=None - ) -> "DatabaseLinkingDialog": - label = "Customize database links for exchanges in the imported database." - return cls.construct_dialog(label, options, parent) - - -class DatabaseLinkingResultsDialog(QtWidgets.QDialog): - """To be used when relinking a database, this dialog will pop up if - some of the exchanges in the database fail to be linked to the new - database. - Up to five of the unlinked activities are printed on the screen, - - """ - - def __init__(self, parent=None): - super().__init__(parent) - - self.setWindowTitle("Relinking database results") - - button = QtWidgets.QDialogButtonBox.Ok - self.buttonBox = QtWidgets.QDialogButtonBox(button) - self.buttonBox.accepted.connect(self.accept) - self.databases_relinked = QtWidgets.QVBoxLayout() - - self.activityToOpen = set() - - self.exchangesUnlinked = QtWidgets.QVBoxLayout() - - self.layout = QtWidgets.QVBoxLayout() - self.layout.addLayout(self.databases_relinked) - self.layout.addLayout(self.exchangesUnlinked) - self.layout.addWidget(self.buttonBox) - self.setLayout(self.layout) - - @classmethod - def construct_results_dialog( - cls, - parent: QtWidgets.QWidget = None, - link_results: dict = None, - unlinked_exchanges: dict = None, - ) -> "DatabaseLinkingResultsDialog": - from activity_browser import app - - obj = cls(parent) - for k, results in link_results.items(): - obj.databases_relinked.addWidget( - QtWidgets.QLabel(f"{k} = {results[1]} successfully linked") - ) - obj.databases_relinked.addWidget( - QtWidgets.QLabel(f"{k} = {results[0]} flows failed to link") - ) - - obj.exchangesUnlinked.addWidget( - QtWidgets.QLabel("Up to 5 unlinked exchanges (click to open)") - ) - for act, key in unlinked_exchanges.items(): - button = QtWidgets.QPushButton(act.as_dict()["name"]) - button.clicked.connect( - lambda: app.actions.ActivityOpen.run([act.key]) - ) - obj.exchangesUnlinked.addWidget(button) - obj.updateGeometry() - - return obj - - @classmethod - def present_relinking_results( - cls, - parent: QtWidgets.QWidget = None, - link_results: dict = None, - unlinked_exchanges: dict = None, - ) -> "DatabaseLinkingResultsDialog": - return cls.construct_results_dialog(parent, link_results, unlinked_exchanges) - - def select_activity_to_open(self, actvty: tuple) -> None: - if actvty in self.activityToOpen: - self.activityToOpen.discard(actvty) - self.activityToOpen.add(actvty) - - def open_activity(self): - return self.activityToOpen - diff --git a/activity_browser/app/actions/database/database_set_readonly.py b/activity_browser/app/actions/database/database_set_readonly.py deleted file mode 100644 index b54696285..000000000 --- a/activity_browser/app/actions/database/database_set_readonly.py +++ /dev/null @@ -1,33 +0,0 @@ -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd - - -class DatabaseSetReadonly(ABAction): - """ - ABAction to set a database as read-only. - - This action allows marking a database as read-only by updating its metadata. - It can also be used to remove the read-only status by setting the `read_only` flag to `False`. - - Attributes: - text (str): The display text for this action. - tool_tip (str): The tooltip text for this action. - """ - - text = "Set database read-only" - tool_tip = "Set this database to read-only" - - @staticmethod - @exception_dialogs - def run(db_name: str, read_only=True): - """ - Execute the action to set the read-only status of a database. - - This method updates the `read_only` flag in the metadata of the specified database. - - Args: - db_name (str): The name of the database to update. - read_only (bool, optional): The desired read-only status. Defaults to True. - """ - bd.databases[db_name]["read_only"] = read_only - bd.databases.flush() diff --git a/activity_browser/app/actions/exchange/exchange_copy_sdf.py b/activity_browser/app/actions/exchange/exchange_copy_sdf.py deleted file mode 100644 index f7d2c0b8b..000000000 --- a/activity_browser/app/actions/exchange/exchange_copy_sdf.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Any, List - -import pandas as pd - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils import commontasks -from activity_browser.ui.icons import qicons - - -class ExchangeCopySDF(ABAction): - """ - ABAction to copy the exchange information in SDF format to the clipboard. - """ - - icon = qicons.superstructure - text = "Exchanges for scenario difference file" - - @staticmethod - @exception_dialogs - def run(exchanges: List[Any]): - data = commontasks.get_exchanges_in_scenario_difference_file_notation(exchanges) - df = pd.DataFrame(data) - df.to_clipboard(excel=True, index=False) diff --git a/activity_browser/app/actions/exchange/exchange_delete.py b/activity_browser/app/actions/exchange/exchange_delete.py deleted file mode 100644 index 2a22a2f02..000000000 --- a/activity_browser/app/actions/exchange/exchange_delete.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Any, List - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - -class ExchangeDelete(ABAction): - """ - ABAction to delete one or more exchanges from an activity. - """ - - icon = qicons.delete - text = "Delete exchange(s)" - - @staticmethod - @exception_dialogs - def run(exchanges: List[Any]): - for exchange in exchanges: - exchange.delete() diff --git a/activity_browser/app/actions/exchange/exchange_formula_remove.py b/activity_browser/app/actions/exchange/exchange_formula_remove.py deleted file mode 100644 index 4afdbcbb7..000000000 --- a/activity_browser/app/actions/exchange/exchange_formula_remove.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Any, List - -from bw2data.parameters import ParameterizedExchange - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - -class ExchangeFormulaRemove(ABAction): - """ - ABAction to clear the formula's of one or more exchanges. - """ - - icon = qicons.delete - text = "Clear formula(s)" - - @staticmethod - @exception_dialogs - def run(exchanges: List[Any]): - for exchange in exchanges: - if "formula" not in exchange: - return - - del exchange["formula"] - exchange.save() - - ParameterizedExchange.delete().where(ParameterizedExchange.exchange == exchange._document.id).execute() diff --git a/activity_browser/app/actions/exchange/exchange_modify.py b/activity_browser/app/actions/exchange/exchange_modify.py deleted file mode 100644 index ae52995f4..000000000 --- a/activity_browser/app/actions/exchange/exchange_modify.py +++ /dev/null @@ -1,57 +0,0 @@ -from bw2data.proxies import ExchangeProxyBase - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from bw2data.parameters import ActivityParameter -from activity_browser.ui.icons import qicons - -from ..parameter.parameter_new_automatic import ParameterNewAutomatic -from .exchange_formula_remove import ExchangeFormulaRemove - - - - -class ExchangeModify(ABAction): - """ - ABAction to modify an exchange with the supplied data. - """ - - icon = qicons.delete - text = "Modify exchange" - - @classmethod - @exception_dialogs - def run(cls, exchange: ExchangeProxyBase, data: dict): - # remove the formula if it is an empty string - if "formula" in exchange and data.get("formula") == "": - del data["formula"] - ExchangeFormulaRemove.run([exchange]) - - for key, value in data.items(): - exchange[key] = value - - exchange.save() - - if "formula" in data: - cls.parameterize_exchanges(exchange.output.key) - - @staticmethod - def parameterize_exchanges(key: tuple) -> None: - """Used whenever a formula is set on an exchange in an activity. - - If no `ActivityParameter` exists for the key, generate one immediately - """ - act = bd.get_activity(key) - query = (ActivityParameter.database == key[0]) & ( - ActivityParameter.code == key[1] - ) - - if not ActivityParameter.select().where(query).count(): - ParameterNewAutomatic.run([key]) - - group = ActivityParameter.get(query).group - - with bd.parameters.db.atomic(): - bd.parameters.remove_exchanges_from_group(group, act) - bd.parameters.add_exchanges_to_group(group, act) - ActivityParameter.recalculate_exchanges(group) diff --git a/activity_browser/app/actions/exchange/exchange_new.py b/activity_browser/app/actions/exchange/exchange_new.py deleted file mode 100644 index 454ce537a..000000000 --- a/activity_browser/app/actions/exchange/exchange_new.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import List - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils import commontasks -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - -class ExchangeNew(ABAction): - """ - ABAction to create a new exchange for an activity. - """ - - icon = qicons.add - text = "Add exchanges" - - @staticmethod - @exception_dialogs - def run(from_keys: List[tuple], to_key: tuple, type: str, amount: float = 1): - to_activity = bd.get_activity(to_key) - for from_key in from_keys: - exchange = to_activity.new_exchange(input=from_key, type=type, amount=amount) - exchange.save() diff --git a/activity_browser/app/actions/exchange/exchange_sdf_to_clipboard.py b/activity_browser/app/actions/exchange/exchange_sdf_to_clipboard.py deleted file mode 100644 index b775d5aad..000000000 --- a/activity_browser/app/actions/exchange/exchange_sdf_to_clipboard.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import List - -import bw2data as bd -import bw_functional as bf - -from activity_browser.bwutils.commontasks import refresh_edge, exchanges_to_sdf -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - -class ExchangeSDFToClipboard(ABAction): - """ - ABAction to open one or more supplied activities in an activity tab by employing signals. - - TODO: move away from using signals like this. Probably add a method to the MainWindow to add a panel instead. - """ - - icon = qicons.superstructure - text = "SDF to clipboard" - - @staticmethod - @exception_dialogs - def run(exchanges: List[int | bd.Edge]): - exchanges = [refresh_edge(edge) for edge in exchanges] - - virtual_exchanges = [] - for exchange in exchanges: - if isinstance(exchange, bf.MFExchange): - virtual_exchanges += exchange.virtual_edges - else: - virtual_exchanges.append(exchange.as_dict()) - - df = exchanges_to_sdf(virtual_exchanges) - df.to_clipboard(excel=True, index=False) diff --git a/activity_browser/app/actions/exchange/exchange_uncertainty_modify.py b/activity_browser/app/actions/exchange/exchange_uncertainty_modify.py deleted file mode 100644 index cc105def1..000000000 --- a/activity_browser/app/actions/exchange/exchange_uncertainty_modify.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import Any, List - -import bw2data as bd - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons -from activity_browser.ui.dialogs import UncertaintyDialog - - -class ExchangeUncertaintyModify(ABAction): - """ - ABAction to open the UncertaintyWizard for an exchange - """ - - icon = qicons.edit - text = "Modify uncertainty" - - @staticmethod - @exception_dialogs - def run(exchanges: List[bd.Edge]): - - ok, uc_dict = UncertaintyDialog.get_uncertainty_dict( - parent=app.main_window, - initial=exchanges[0].uncertainty, - ) - - if not ok: - return - - for exchange in exchanges: - for key, value in uc_dict.items(): - exchange[key] = value - exchange.save() diff --git a/activity_browser/app/actions/exchange/exchange_uncertainty_remove.py b/activity_browser/app/actions/exchange/exchange_uncertainty_remove.py deleted file mode 100644 index 22ecbd12c..000000000 --- a/activity_browser/app/actions/exchange/exchange_uncertainty_remove.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Any, List - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils import uncertainty -from activity_browser.ui.icons import qicons - - -class ExchangeUncertaintyRemove(ABAction): - """ - ABAction to clear the uncertainty of one or multiple exchanges. - """ - - icon = qicons.delete - text = "Remove uncertainty/-ies" - - @staticmethod - @exception_dialogs - def run(exchanges: List[Any]): - for exchange in exchanges: - for key, value in uncertainty.EMPTY_UNCERTAINTY.items(): - exchange[key] = value - - exchange.save() diff --git a/activity_browser/app/actions/metadatastore_cache_clear.py b/activity_browser/app/actions/metadatastore_cache_clear.py deleted file mode 100644 index 30fd619c1..000000000 --- a/activity_browser/app/actions/metadatastore_cache_clear.py +++ /dev/null @@ -1,20 +0,0 @@ -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - -import bw2data as bd - -from .project.project_switch import ProjectSwitch - - -class MetaDataStoreCacheClear(ABAction): - - icon = qicons.right - text = "Clear Metadata Store Cache" - tool_tip = "Clear the Metadata Store cache and reload the current project" - - @staticmethod - @exception_dialogs - def run(): - app.metadata.clear_cache() - ProjectSwitch.run(bd.projects.current, reload=True) diff --git a/activity_browser/app/actions/metadatastore_open.py b/activity_browser/app/actions/metadatastore_open.py deleted file mode 100644 index 51d6b93ca..000000000 --- a/activity_browser/app/actions/metadatastore_open.py +++ /dev/null @@ -1,21 +0,0 @@ -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons -from activity_browser.ui.core.application import global_shortcut - - - - -class MetaDataStoreOpen(ABAction): - - icon = qicons.right - text = "Open activity / activities" - - @staticmethod - @global_shortcut("Ctrl+Shift+M") - @exception_dialogs - def run(): - from activity_browser.app import pages - page = pages.MetaDataStorePage() - central = app.main_window.centralWidget() - central.addToGroup("DEBUG", page) diff --git a/activity_browser/app/actions/method/cf_amount_modify.py b/activity_browser/app/actions/method/cf_amount_modify.py deleted file mode 100644 index 2c5b1cb22..000000000 --- a/activity_browser/app/actions/method/cf_amount_modify.py +++ /dev/null @@ -1,29 +0,0 @@ -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - -class CFAmountModify(ABAction): - """ - ABAction to modify the amount of a characterization factor within a method. Updates the CF-Tuple's second value - directly if there's no uncertainty dict. Otherwise, changes the "amount" from the uncertainty dict. - """ - - icon = qicons.edit - text = "Modify amount" - - @staticmethod - @exception_dialogs - def run(method: tuple | bd.Method, key: int | tuple, amount: float): - if isinstance(method, bd.Method): - method = method.name - - method = bd.Method(method) - method_dict = {cf[0]: cf[1] for cf in method.load()} - - if isinstance(method_dict[key], dict): - method_dict[key]["amount"] = amount - else: - method_dict[key] = amount - - method.write(list(method_dict.items())) diff --git a/activity_browser/app/actions/method/cf_new.py b/activity_browser/app/actions/method/cf_new.py deleted file mode 100644 index 305062496..000000000 --- a/activity_browser/app/actions/method/cf_new.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import List - -from qtpy import QtWidgets - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - -class CFNew(ABAction): - """ - ABAction to add a new characterization flow to a method through one or more elementary-flow keys. - """ - - icon = qicons.add - text = "New characterization factor" - - @staticmethod - @exception_dialogs - def run(method_name: tuple, keys: List[tuple]): - # load old cf's from the Method - method_dict = {cf[0]: cf[1] for cf in bd.Method(method_name).load()} - - # use only the keys that don't already exist within the method - unique_keys = [key for key in keys if key not in method_dict] - - # if there are non-unique keys warn the user that these won't be added - if len(unique_keys) < len(keys): - QtWidgets.QMessageBox.warning( - app.main_window, - "Duplicate characterization factors", - "One or more of these elementary flows already exist within this method. Duplicate flows will not be " - "added", - ) - - # return if there are no new keys - if not unique_keys: - return - - # add the new keys to the method dictionary - for key in unique_keys: - method_dict[key] = 0.0 - - # write the updated dict to the method - bd.Method(method_name).write(list(method_dict.items())) diff --git a/activity_browser/app/actions/method/cf_remove.py b/activity_browser/app/actions/method/cf_remove.py deleted file mode 100644 index 5b79b7a83..000000000 --- a/activity_browser/app/actions/method/cf_remove.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import List - -from qtpy import QtWidgets - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - -class CFRemove(ABAction): - """ - ABAction to remove one or more Characterization Factors from a method. First ask for confirmation and return if the - user cancels. Otherwise instruct the ImpactCategoryController to remove the selected Characterization Factors. - """ - - icon = qicons.delete - text = "Remove CF('s)" - - @staticmethod - @exception_dialogs - def run(method_name: tuple, char_factors: List[tuple]): - # ask the user whether they are sure to delete the calculation setup - warning = QtWidgets.QMessageBox.warning( - app.main_window, - "Deleting Characterization Factors", - f"Are you sure you want to delete {len(char_factors)} CF('s)?", - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.No, - ) - - # return if the users cancels - if warning == QtWidgets.QMessageBox.No: - return - - method = bd.Method(method_name) - method_dict = {cf[0]: cf[1] for cf in method.load()} - - for cf in char_factors: - method_dict.pop(cf[0]) - - method.write(list(method_dict.items())) diff --git a/activity_browser/app/actions/method/cf_uncertainty_modify.py b/activity_browser/app/actions/method/cf_uncertainty_modify.py deleted file mode 100644 index 2670650bd..000000000 --- a/activity_browser/app/actions/method/cf_uncertainty_modify.py +++ /dev/null @@ -1,47 +0,0 @@ -from functools import partial -from typing import List - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons -from activity_browser.ui.dialogs import UncertaintyDialog - - -class CFUncertaintyModify(ABAction): - """ - ABAction to launch the UncertaintyDialog for Characterization Factor and handles the output by writing the - uncertainty data using the ImpactCategoryController to the Characterization Factor in question. - """ - - icon = qicons.edit - text = "Modify uncertainty" - - @classmethod - @exception_dialogs - def run(cls, method_name: tuple, char_factors: List[tuple], uncertainty_dict: dict = None): - - if uncertainty_dict is None: - initial = char_factors[0][1] - initial = initial if isinstance(initial, dict) else {} - - ok, uncertainty_dict = UncertaintyDialog.get_uncertainty_dict( - parent=app.main_window, - initial=initial, - ) - - if not ok: - return - - method = bd.Method(method_name) - method_dict = {cf[0]: cf[1] for cf in method.load()} - - for cf in char_factors: - if isinstance(cf[1], dict): - cf[1].update(uncertainty_dict) - method_dict[cf[0]] = cf[1] - else: - uncertainty_dict["amount"] = cf[1] - method_dict[cf[0]] = uncertainty_dict - - method.write(list(method_dict.items())) diff --git a/activity_browser/app/actions/method/cf_uncertainty_remove.py b/activity_browser/app/actions/method/cf_uncertainty_remove.py deleted file mode 100644 index c26a4ad6d..000000000 --- a/activity_browser/app/actions/method/cf_uncertainty_remove.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import List - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - -class CFUncertaintyRemove(ABAction): - """ - ABAction to remove the uncertainty from one or multiple Characterization Factors. - """ - - icon = qicons.clear - text = "Remove uncertainty" - - @staticmethod - @exception_dialogs - def run(method_name: tuple, char_factors: List[tuple]): - # create a list of CF's of which the uncertainty dict is removed - cleaned_cfs = [] - for cf in char_factors: - # if there's no uncertainty dict, we may continue - if not isinstance(cf[1], dict): - continue - - # else replace the uncertainty dict with the float found in the amount field of said dict - cleaned_cfs.append((cf[0], cf[1]["amount"])) - - # if the list is empty we can return - if not cleaned_cfs: - return - - # else write the cf's to the method - method = bd.Method(method_name) - method_dict = {cf[0]: cf[1] for cf in method.load()} - - for cf in cleaned_cfs: - method_dict[cf[0]] = cf[1] - - method.write(list(method_dict.items())) 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 cc2fc9c29..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 methods 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/importer/method_importer_ecoinvent.py b/activity_browser/app/actions/method/importer/method_importer_ecoinvent.py deleted file mode 100644 index a847e3b1b..000000000 --- a/activity_browser/app/actions/method/importer/method_importer_ecoinvent.py +++ /dev/null @@ -1,158 +0,0 @@ -from loguru import logger - -from qtpy import QtWidgets, QtCore -from qtpy.QtCore import Signal, SignalInstance - -from activity_browser import app -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.core import threading - - - - -class MethodImporterEcoinvent(ABAction): - """ABAction to import methods from ecoinvent""" - - icon = icons.qicons.import_db - text = "Import methods from ecoinvent excel format" - tool_tip = "Import methods from ecoinvent excel format" - - @classmethod - @exception_dialogs - def run(cls): - # get the path from the user - path, _ = QtWidgets.QFileDialog.getOpenFileName( - parent=app.main_window, - caption='Choose ecoinvent methods excel to import', - filter='excel spreadsheet (*.xlsx);; All files (*.*)' - ) - if not path: - return - - # initialize the import thread, setting needed attributes - extract_thread = ExtractExcelThread(app.application) - extract_thread.path = path - 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() - - @staticmethod - def write_database(importer: EcoinventLCIAImporter): - # show the import setup dialog - import_dialog = ImportSetupDialog(importer, app.main_window) - if import_dialog.exec_() == QtWidgets.QDialog.Rejected: - return - - # setup the importer thread - importer_thread = ImportExcelThread(app.application) - importer_thread.importer = importer - importer_thread.biosphere_name = import_dialog.biosphere_name - importer_thread.prepend = import_dialog.prepend - - # setup a progress dialog - progress_dialog = widgets.ABProgressDialog.get_connected_dialog("Importing Impact Categories") - importer_thread.finished.connect(progress_dialog.deleteLater) - - progress_dialog.show() - importer_thread.start() - - -class ImportSetupDialog(QtWidgets.QDialog): - biosphere_name: str - prepend: str - - def __init__(self, importer: EcoinventLCIAImporter, parent=None): - super().__init__(parent) - self.importer = importer - - self.setWindowTitle("Import methods from ecoinvent Excel") - - self.db_chooser = widgets.ABComboBox.get_database_combobox(self) - self.button_comp = composites.HorizontalButtonsComposite("Cancel", "*OK") - - self.info = QtWidgets.QLabel() - self.info.setWordWrap(True) - self.info.setTextFormat(QtCore.Qt.RichText) - - self.prepend_label = QtWidgets.QLabel("Prepend method names") - - self.prepend_textbox = QtWidgets.QLineEdit() - self.prepend_textbox.setPlaceholderText("Enter name prepend") - 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) - - # Create final layout - layout = QtWidgets.QVBoxLayout() - layout.addWidget(QtWidgets.QLabel("Choose biosphere database:")) - layout.addWidget(self.db_chooser) - layout.addWidget(self.prepend_label) - layout.addWidget(self.prepend_textbox) - layout.addWidget(self.info) - layout.addWidget(self.button_comp) - - # Set the dialog layout - self.setLayout(layout) - self.validate() - self.check_overwrite() - - def check_overwrite(self, prepend=None) -> int: - overwrite = 0 - for name in [x["name"] for x in self.importer.data]: - if prepend: - name = tuple([prepend, *name]) - - if name in bd.methods: - overwrite += 1 - - if not overwrite: - self.info.setText("") - return overwrite - - self.info.setText( - f"

This action will overwrite {overwrite} impact categories

" - ) - - return overwrite - - def validate(self): - """Validate the user input and enable the OK button if all is clear""" - valid = True - self.button_comp["OK"].setEnabled(valid) - - def accept(self): - """Correctly set the dialog's attributes for further use in the action""" - self.biosphere_name = self.db_chooser.currentText() - self.prepend = self.prepend_textbox.text() - super().accept() - - -class ExtractExcelThread(threading.ABThread): - loaded: SignalInstance = Signal(EcoinventLCIAImporter) - path: str - - def run_safely(self): - importer = EcoinventLCIAImporter.setup_with_ei_excel(self.path) - self.loaded.emit(importer) - - -class ImportExcelThread(threading.ABThread): - biosphere_name: str - prepend: str - importer: EcoinventLCIAImporter - - def run_safely(self): - self.importer.set_biosphere(self.biosphere_name) - self.importer.apply_strategies() - self.importer.prepend_methods(self.prepend) - self.importer.write_methods(overwrite=True) - diff --git a/activity_browser/app/actions/method/method_delete.py b/activity_browser/app/actions/method/method_delete.py deleted file mode 100644 index dac6cd4b0..000000000 --- a/activity_browser/app/actions/method/method_delete.py +++ /dev/null @@ -1,93 +0,0 @@ -from os import name -from typing import List -from loguru import logger - -from qtpy import QtWidgets - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - - - -class MethodDelete(ABAction): - """ - Delete one or more impact assessment methods (impact categories). - - Flow: - - Confirm with the user. - - Deregister all selected methods from Brightway. - - Update all Brightway calculation setups by removing the deleted methods from their - 'ia' list (if present), and serialize the updated setups. - - Notes: - - Calculation setups can store method identifiers either as tuples (recommended) or - sometimes as strings for single-level names; the cleanup accounts for both. - """ - - icon = qicons.delete - text = "Delete Impact Category" - - @staticmethod - @exception_dialogs - def run(methods: List[tuple]): - # check whether we're dealing with a leaf or node. If it's a node, select all underlying methods for deletion - all_methods = [bd.Method(method) for method in methods] - - if len(all_methods) == 1: - warning_text = f"Are you sure you want to delete this method?\n\n{methods[0]}" - else: - warning_text = f"Are you sure you want to delete {len(all_methods)} methods?" - - # warn the user about the pending deletion - warning = QtWidgets.QMessageBox.warning( - app.main_window, - "Deleting Method", - warning_text, - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.No, - ) - # return if the users cancels - if warning == QtWidgets.QMessageBox.No: - return - - # collect names for calculation setup cleanup - to_remove = {m.name for m in all_methods} - - # delete all methods by deregistering them - for method in all_methods: - method.deregister() - logger.info(f"Deleted method {method.name}") - - # remove deleted methods from all calculation setups - MethodDelete.remove_methods_from_calculation_setups(to_remove) - - @staticmethod - def remove_methods_from_calculation_setups(method_names: set[tuple]) -> None: - """ - Remove given method names from all calculation setups' 'ia' lists and serialize. - """ - try: - changed_any = False - - for cs_name, cs in bd.calculation_setups.items(): - ia = cs.get("ia", []) - - for name in method_names: - if name not in ia: - continue # name not present, skip - - ia.remove(name) - changed_any = True - - logger.info( - f"Updated calculation setup '{cs_name}': removed impact category {name}" - ) - - - if changed_any: - bd.calculation_setups.serialize() - except Exception: - logger.exception("Failed to update calculation setups after method rename") diff --git a/activity_browser/app/actions/method/method_duplicate.py b/activity_browser/app/actions/method/method_duplicate.py deleted file mode 100644 index fb63a406a..000000000 --- a/activity_browser/app/actions/method/method_duplicate.py +++ /dev/null @@ -1,149 +0,0 @@ -from typing import List -from loguru import logger - -from qtpy import QtWidgets - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - -from .method_open import MethodOpen - - - - -class MethodDuplicate(ABAction): - """ - ABAction to duplicate a method, or node with all underlying methods to a new name specified by the user. - """ - - icon = qicons.copy - text = "Duplicate Impact Category" - - @staticmethod - @exception_dialogs - def run(methods: List[tuple], level: str = None): - # this action can handle only one selected method for now - selected_method = methods[0] - - # check whether we're dealing with a leaf or node. If it's a node, select all underlying methods for duplication - if level is not None and level != "leaf": - all_methods = [ - bd.Method(method) - for method in bd.methods - if set(selected_method).issubset(method) - ] - else: - all_methods = [bd.Method(selected_method)] - - # retrieve the new name(s) from the user and return if canceled - dialog = TupleNameDialog.get_combined_name( - app.main_window, - "Impact category name", - "Combined name:", - selected_method, - " - Copy", - ) - if dialog.exec_() != TupleNameDialog.Accepted: - return - - # for each method to be duplicated, construct a new location - location = dialog.result_tuple - new_names = [location + method.name[len(location) :] for method in all_methods] - - # instruct the ImpactCategoryController to duplicate the methods to the new locations - for method, new_name in zip(all_methods, new_names): - if new_name in methods: - raise Exception("New method name already in use") - method.copy(new_name) - logger.info(f"Copied method {method.name} into {new_name}") - - MethodOpen.run(new_names) - - -class TupleNameDialog(QtWidgets.QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.name_label = QtWidgets.QLabel("New name") - self.view_name = QtWidgets.QLabel() - - self.input_fields = [] - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - layout = QtWidgets.QVBoxLayout() - row = QtWidgets.QHBoxLayout() - row.addWidget(self.name_label) - row.addWidget(self.view_name) - layout.addLayout(row) - self.input_box = QtWidgets.QGroupBox(self) - input_field_layout = QtWidgets.QVBoxLayout() - self.input_box.setLayout(input_field_layout) - layout.addWidget(self.input_box) - layout.addWidget(self.buttons) - self.setLayout(layout) - - @property - def combined_names(self) -> str: - """Reads all of the input fields in order and returns a string.""" - return ", ".join(self.result_tuple) - - @property - def result_tuple(self) -> tuple: - return tuple([f.text() for f in self.input_fields if f.text()]) - - def changed(self) -> None: - """ - Actions when the text within the TupleNameDialog is edited by the user - """ - # rebuild the combined name example - self.view_name.setText(f"'({self.combined_names})'") - - # disable the button (and its outline) when all fields are empty - if self.combined_names == "": - self.buttons.buttons()[0].setDefault(False) - self.buttons.buttons()[0].setDisabled(True) - # enable when that's not the case (anymore) - else: - self.buttons.buttons()[0].setDisabled(False) - self.buttons.buttons()[0].setDefault(True) - - def add_input_field(self, text: str) -> None: - edit = QtWidgets.QLineEdit(text, self) - edit.textChanged.connect(self.changed) - self.input_fields.append(edit) - self.input_box.layout().addWidget(edit) - - @classmethod - def get_combined_name( - cls, - parent: QtWidgets.QWidget, - title: str, - label: str, - fields: tuple, - extra: str = "Extra", - ) -> "TupleNameDialog": - """ - Set-up a TupleNameDialog pop-up with supplied title + label. Construct fields - for each field of the supplied tuple. Last field of the tuple is appended with - the extra string, to avoid duplicates. - """ - obj = cls(parent) - obj.setWindowTitle(title) - obj.name_label.setText(label) - - # set up a field for each tuple element - for i, field in enumerate(fields): - field_content = str(field) - - # if it's the last element, add extra to the string - if i + 1 == len(fields): - field_content += extra - obj.add_input_field(field_content) - obj.input_box.updateGeometry() - obj.changed() - return obj diff --git a/activity_browser/app/actions/method/method_meta_modify.py b/activity_browser/app/actions/method/method_meta_modify.py deleted file mode 100644 index f917f82ff..000000000 --- a/activity_browser/app/actions/method/method_meta_modify.py +++ /dev/null @@ -1,25 +0,0 @@ -from loguru import logger - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - - - -class MethodMetaModify(ABAction): - """ - """ - - icon = qicons.delete - text = "Modify Impact Category metadata" - - @staticmethod - @exception_dialogs - def run(method_name: tuple[str], key: str, value: str): - if method_name not in bd.methods: - logger.warning(f"Can't modify metadata for method {method_name} - method not found") - return - - bd.methods[method_name][key] = value - bd.methods.flush() diff --git a/activity_browser/app/actions/method/method_new.py b/activity_browser/app/actions/method/method_new.py deleted file mode 100644 index 8fa03e688..000000000 --- a/activity_browser/app/actions/method/method_new.py +++ /dev/null @@ -1,77 +0,0 @@ -from loguru import logger - -from qtpy import QtWidgets - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons -from activity_browser.ui import dialogs - - -class MethodNew(ABAction): - """ - ABAction to create a new, empty impact category and open it in edit mode. - - This action prompts the user for a new method name using a list edit dialog, - validates the input, creates an empty method in Brightway2, and opens it - in the ImpactCategoryDetails page in edit mode so the user can start adding - characterization factors. - - Steps: - - Open a dialog to prompt the user for the new method name (as a tuple). - - Validate the new name to ensure it is not empty and does not already exist. - - Create and register a new empty method in Brightway2. - - Open the method in the ImpactCategoryDetails page. - - Set the page to edit mode so the user can add characterization factors. - """ - - icon = qicons.add - text = "New impact category" - - @staticmethod - @exception_dialogs - def run(): - # Open dialog to get new method name - dialog = dialogs.ABListEditDialog(("New Impact Category",), parent=app.main_window) - dialog.setWindowTitle("New Impact Category") - - if dialog.exec_() != QtWidgets.QDialog.Accepted: - return - - new_name = dialog.get_data(as_tuple=True) - - # Validate new name - if len(new_name) == 0: - QtWidgets.QMessageBox.warning( - app.main_window, - "Invalid Name", - "Impact category name cannot be empty.", - ) - return - - if new_name in bd.methods: - QtWidgets.QMessageBox.warning( - app.main_window, - "Name Already Exists", - f"An impact category with the name '{' | '.join(new_name)}' already exists.", - ) - return - - # Create new empty method - method = bd.Method(new_name) - method.register() - method.write([]) # Write empty list of characterization factors - - logger.info(f"Created new impact category: {new_name}") - - # Open the method in the ImpactCategoryDetails page - from activity_browser.app import pages - - page = pages.ImpactCategoryDetailsPage(new_name) - central = app.main_window.centralWidget() - central.addToGroup("Characterization Factors", page) - - # Set the page to edit mode - page.is_editable = True - page.sync() diff --git a/activity_browser/app/actions/method/method_open.py b/activity_browser/app/actions/method/method_open.py deleted file mode 100644 index c6f9cc49d..000000000 --- a/activity_browser/app/actions/method/method_open.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import List - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - -class MethodOpen(ABAction): - """ - ABAction to open one or more supplied methods in a method tab by employing signals. - - TODO: move away from using signals like this. Probably add a method to the MainWindow to add a panel instead. - """ - - icon = qicons.right - text = "Open Impact Category" - - @staticmethod - @exception_dialogs - def run(method_names: List[tuple]): - from activity_browser.app import pages - - for name in method_names: - page = pages.ImpactCategoryDetailsPage(name) - central = app.main_window.centralWidget() - - central.addToGroup("Characterization Factors", page) - diff --git a/activity_browser/app/actions/method/method_rename.py b/activity_browser/app/actions/method/method_rename.py deleted file mode 100644 index 7942dd8a6..000000000 --- a/activity_browser/app/actions/method/method_rename.py +++ /dev/null @@ -1,112 +0,0 @@ -from typing import List -from loguru import logger - -from qtpy import QtWidgets - -import bw2data as bd - -from activity_browser import app -from activity_browser.ui import dialogs -from activity_browser.app.actions.base import ABAction, exception_dialogs - - - - -class MethodRename(ABAction): - """ - Rename an existing impact assessment method (impact category). - - Flow: - - Ensure only one method is selected and it exists. - - Prompt the user for the new name and validate it. - - Copy the method to the new name and process it. - - Update all Brightway calculation setups by replacing the old method in 'ia' lists with - the new name and serialize the updates. - - Emit a rename signal and deregister the old method. - - Raises: - - ValueError: If more than one method is provided for renaming. - - RuntimeError: If the method does not exist, the new name is empty, or the new name already exists. - """ - - text = "Rename Impact Category" - - @staticmethod - @exception_dialogs - def run(method_name: tuple[str] | list[tuple[str]]): - # safeguard: only allow renaming one method at a time - if isinstance(method_name, list): - if len(method_name) != 1 or not isinstance(method_name[0], tuple): - raise ValueError("Can only rename one method at a time.") - method_name = method_name[0] - - # check if method exists - if method_name not in bd.methods: - raise RuntimeError(f"Method {method_name} does not exist.") - - method = bd.Method(method_name) - - # open dialog to get new name - dialog = dialogs.ABListEditDialog( - method_name, - title="Rename Impact Category", - parent=app.main_window, - ) - - # execute the dialog and check for acceptance - if dialog.exec_() != QtWidgets.QDialog.Accepted: - return - - new_name = dialog.get_data(as_tuple=True) - - # check new name validity - if new_name == method_name: - return # no change - - if len(new_name) == 0: - raise RuntimeError("Method name cannot be empty.") - - if new_name in bd.methods: - raise RuntimeError(f"Method {new_name} already exists.") - - # copy method to new name and process - method.copy(new_name).process() - - # Update any calculation setups that reference this method - MethodRename.rename_method_in_calculation_setups(method_name, new_name) - - # this should not happen like this, as the model and therefore signals should be handled declaritavely, - # but since method renaming is not native to bw2data we have to do it manually here - app.signals.method.renamed.emit(method_name, new_name) - - # deregister old method - method.deregister() - - @staticmethod - def rename_method_in_calculation_setups(old_name: tuple, new_name: tuple) -> None: - """Replace occurrences of old_name with new_name in all CS 'ia' lists and serialize. - - Handles both tuple and single-string method keys. Best-effort: logs on failure - without blocking the rename flow. - """ - try: - changed_any = False - - for cs_name, cs in bd.calculation_setups.items(): - ia = cs.get("ia", []) - - if old_name not in ia: - continue - - i = ia.index(old_name) - ia[i] = new_name - - changed_any = True - logger.info( - f"Updated calculation setup '{cs_name}': renamed impact category {old_name} -> {new_name}" - ) - - if changed_any: - bd.calculation_setups.serialize() - except Exception: - logger.exception("Failed to update calculation setups after method rename") diff --git a/activity_browser/app/actions/migrations_install.py b/activity_browser/app/actions/migrations_install.py deleted file mode 100644 index 753445abb..000000000 --- a/activity_browser/app/actions/migrations_install.py +++ /dev/null @@ -1,41 +0,0 @@ -from qtpy import QtWidgets - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui import icons -from activity_browser.mod.bw2io.migrations import ab_create_core_migrations -from activity_browser.ui.core import threading - - -class MigrationsInstall(ABAction): - """ - ABAction to install the default migrations from bw2io - """ - - icon = icons.qicons.import_db - text = "Install default migrations" - - @staticmethod - @exception_dialogs - def run(): - def update_dialog_slot(progress: int, label: str): - dialog.setValue(progress) - dialog.setLabelText(label) - - - dialog = QtWidgets.QProgressDialog(app.main_window) - dialog.setWindowTitle("Installing migrations") - dialog.setMaximum(100) - dialog.setCancelButton(None) - - thread = MigrationsInstallThread(app.application) - - thread.status.connect(update_dialog_slot) - - thread.start() - dialog.exec_() - - -class MigrationsInstallThread(threading.ABThread): - def run_safely(self): - ab_create_core_migrations() diff --git a/activity_browser/app/actions/node_select_open.py b/activity_browser/app/actions/node_select_open.py deleted file mode 100644 index 5050f7fe6..000000000 --- a/activity_browser/app/actions/node_select_open.py +++ /dev/null @@ -1,29 +0,0 @@ -from loguru import logger - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons -from activity_browser.ui.core.application import global_shortcut - -from .activity.activity_open import ActivityOpen - -class NodeSelectOpen(ABAction): - - icon = qicons.search - text = "Search project" - - @staticmethod - @global_shortcut("Ctrl+Shift+F") - @exception_dialogs - def run(): - from activity_browser.app import dialogs - dialog = dialogs.NodeSelectDialog(parent=app.main_window, drag_enabled=True) - - dialog.exec_() - if dialog.result() != dialog.DialogCode.Accepted: - return - - selected_node = dialog.get_selected_node() - if selected_node: - logger.debug(f"Opening node: {selected_node}") - ActivityOpen.run([selected_node]) \ No newline at end of file diff --git a/activity_browser/app/actions/parameter/parameter_clear_broken.py b/activity_browser/app/actions/parameter/parameter_clear_broken.py deleted file mode 100644 index f020bb355..000000000 --- a/activity_browser/app/actions/parameter/parameter_clear_broken.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import Any - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from bw2data.parameters import (ActivityParameter, Group, - GroupDependency, - parameters) -from activity_browser.ui.icons import qicons - - -class ParameterClearBroken(ABAction): - """ - Take the given information and attempt to remove all the downstream parameter information. - """ - - icon = qicons.delete - text = "Clear broken parameter" - - @staticmethod - @exception_dialogs - def run(parameter: Any): - db = parameter.database - code = parameter.code - group = parameter.group - - # I'm not sure this is right, because you're removing all the exchanges from the group... - parameters.remove_exchanges_from_group(group, None, False) - ActivityParameter.delete().where( - (ActivityParameter.database == db) & (ActivityParameter.code == code) - ).execute() - - # Also clear Group if it is not in use anymore - if ( - not ActivityParameter.select() - .where(ActivityParameter.group == parameter.group) - .exists() - ): - Group.delete().where(Group.name == group).execute() - GroupDependency.delete().where(GroupDependency.group == group).execute() diff --git a/activity_browser/app/actions/parameter/parameter_delete.py b/activity_browser/app/actions/parameter/parameter_delete.py deleted file mode 100644 index d29ae2297..000000000 --- a/activity_browser/app/actions/parameter/parameter_delete.py +++ /dev/null @@ -1,67 +0,0 @@ -from typing import Any - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from bw2data import get_activity -from bw2data.parameters import (ActivityParameter, Group, - GroupDependency, - parameters) -from activity_browser.ui.icons import qicons -from activity_browser.bwutils.utils import Parameter - - -class ParameterDelete(ABAction): - """ - ABAction to delete an existing parameter. - """ - - icon = qicons.delete - text = "Delete parameter..." - - @staticmethod - @exception_dialogs - def run(parameter: Any or list[Any]): - if not isinstance(parameter, list): - parameter = [parameter] - - for parameter in parameter: - if isinstance(parameter, Parameter): - parameter = parameter.to_peewee_model() - - if isinstance(parameter, ActivityParameter): - db = parameter.database - code = parameter.code - amount = ( - ActivityParameter.select() - .where( - (ActivityParameter.database == db) - & (ActivityParameter.code == code) - ) - .count() - ) - - if amount > 1: - parameter.delete_instance() - else: - group = parameter.group - act = get_activity((db, code)) - parameters.remove_from_group(group, act) - # Also clear the group if there are no more parameters in it - - if ( - not ActivityParameter.select() - .where(ActivityParameter.group == group) - .exists() - ): - Group.delete().where(Group.name == group).execute() - GroupDependency.delete().where( - GroupDependency.group == group - ).execute() - else: - parameter.delete_instance() - # After deleting things, recalculate and signal changes - parameters.recalculate() - - # No fire when everything is still fresh after recalculation, so need to fire manually to be sure everything is - # updated correctly. - app.signals.parameter.recalculated.emit() diff --git a/activity_browser/app/actions/parameter/parameter_group_delete.py b/activity_browser/app/actions/parameter/parameter_group_delete.py deleted file mode 100644 index b438b747a..000000000 --- a/activity_browser/app/actions/parameter/parameter_group_delete.py +++ /dev/null @@ -1,50 +0,0 @@ -from loguru import logger - -import bw2data as bd -from bw2data.parameters import (ActivityParameter, Group, - GroupDependency, - parameters) - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - - -class ParameterGroupDelete(ABAction): - """ - ABAction to delete an existing parameter. - """ - - icon = qicons.delete - text = "Delete parameter group..." - - @staticmethod - @exception_dialogs - def run(parameter_groups: list[str]): - for group in parameter_groups: - if group in ["project"] + list(bd.databases): - logger.warning(f"Cannot delete built-in parameter group '{group}'. Skipping.") - continue - - group_entry = Group.get(Group.name == group) - - # Delete all parameters in the group - params_in_group = ActivityParameter.select().where(ActivityParameter.group == group) - if any([ActivityParameter.is_dependent_on(p.name, p.group) for p in params_in_group]): - raise Exception(f"Cannot delete parameter group '{group}' because some parameters are dependencies for other parameters.") - - for param in params_in_group: - param.delete_instance() - - # Delete group dependencies - GroupDependency.delete().where(GroupDependency.group == group).execute() - # Delete the group itself - group_entry.delete_instance() - - - # After deleting things, recalculate and signal changes - parameters.recalculate() - - # No fire when everything is still fresh after recalculation, so need to fire manually to be sure everything is - # updated correctly. - app.signals.parameter.recalculated.emit() diff --git a/activity_browser/app/actions/parameter/parameter_modify.py b/activity_browser/app/actions/parameter/parameter_modify.py deleted file mode 100644 index 85528c687..000000000 --- a/activity_browser/app/actions/parameter/parameter_modify.py +++ /dev/null @@ -1,57 +0,0 @@ -from loguru import logger - -import bw2data as bd -from bw2data.parameters import ParameterBase, parameters, ActivityParameter, Group, GroupDependency -from peewee import DoesNotExist - -from activity_browser.ui.icons import qicons -from activity_browser.bwutils.commontasks import refresh_parameter -from activity_browser.bwutils.utils import Parameter -from activity_browser.app.actions.base import ABAction, exception_dialogs - -from .parameter_rename import ParameterRename - - - - -class ParameterModify(ABAction): - """ - ABAction to delete an existing parameter. - """ - - icon = qicons.edit - text = "Modify Parameter" - - @staticmethod - @exception_dialogs - def run(parameter: tuple | Parameter | ParameterBase, field: str, value: any): - parameter = refresh_parameter(parameter) - param_model = parameter.to_peewee_model() - - if field == "data": - param_model.data.update(value) - elif field == "name": - return ParameterRename.run(parameter, value) - elif field in dir(param_model): - setattr(param_model, field, value) - else: - param_model.data.update({field: value}) - - param_model.save() - - if field in ("amount", "formula"): - ParameterModify.fix_broken_groups() - parameters.recalculate() - - @staticmethod - def fix_broken_groups(): - groups = Group.select().execute() - for group in groups: - if group.name == "project" or group.name in bd.databases: - continue - try: - ActivityParameter._static_dependencies(group.name) - except DoesNotExist: - logger.warning(f"Removing broken parameter group {group.name}") - GroupDependency.get(GroupDependency.group == group.name).delete_instance() - group.delete_instance() diff --git a/activity_browser/app/actions/parameter/parameter_new.py b/activity_browser/app/actions/parameter/parameter_new.py deleted file mode 100644 index fea852592..000000000 --- a/activity_browser/app/actions/parameter/parameter_new.py +++ /dev/null @@ -1,208 +0,0 @@ -from typing import Tuple - -from qtpy import QtCore, QtGui, QtWidgets - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils import commontasks as bc -from activity_browser.mod import bw2data as bd -from bw2data.parameters import ActivityParameter -from activity_browser.ui.icons import qicons - -PARAMETER_STRINGS = ( - "Project: Available to all other parameters", - "Database: Available to Database and Activity parameters of the same database", - "Activity: Available to Activity and exchange parameters within the group", -) -PARAMETER_FIELDS = ( - ("name", "amount"), - ("name", "amount", "database"), - ("name", "amount"), -) - - -class ParameterNew(ABAction): - """ - ABAction to create a new Parameter. Opens the ParameterWizard, returns if the wizard is canceled. Else, - checks whether the name is valid, and then instructs the ParameterController to put the new parameter in the - right group. - """ - - icon = qicons.add - text = "New parameter..." - - @staticmethod - @exception_dialogs - def run(activity_key: Tuple[str, str]): - # instantiate the ParameterWizard - wizard = ParameterWizard(activity_key, app.main_window) - - # return if the wizard is canceled - if wizard.exec_() != QtWidgets.QWizard.Accepted: - return - - # gather wizard variables - selection = wizard.selected - data = wizard.param_data - - # check whether the name is valid, otherwise return - name = data.get("name") - if name[0] in ("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-", "#"): - error = QtWidgets.QErrorMessage() - error.showMessage( - "

Parameter names must not start with a digit, hyphen, or hash character

" - ) - error.exec_() - return - - # select the right group and instruct the controller to create the parameter there - if selection == 0: - bd.parameters.new_project_parameters([data]) - elif selection == 1: - db = data.pop("database") - bd.parameters.new_database_parameters([data], db) - elif selection == 2: - group = data.pop("group") - bd.parameters.new_activity_parameters([data], group) - - -class ParameterWizard(QtWidgets.QWizard): - complete = QtCore.Signal(str, str, str) - - def __init__(self, key: tuple, parent=None): - super().__init__(parent) - - self.key = key - self.pages = ( - SelectParameterTypePage(self), - CompleteParameterPage(self), - ) - for i, p in enumerate(self.pages): - self.setPage(i, p) - - @property - def selected(self) -> int: - return self.pages[0].selected - - @property - def param_data(self) -> dict: - data = {field: self.field(field) for field in PARAMETER_FIELDS[self.selected]} - if self.selected == 2: - data["group"] = self._get_group() - data["database"] = self.key[0] - data["code"] = self.key[1] - return data - - def _get_group(self): - query = (ActivityParameter.database == self.key[0]) & ( - ActivityParameter.code == self.key[1] - ) - - if not ActivityParameter.select().where(query).count(): - app.actions.ParameterNewAutomatic.run([self.key]) - - return ActivityParameter.get(query).group - - -class SelectParameterTypePage(QtWidgets.QWizardPage): - def __init__(self, parent): - super().__init__(parent) - self.setTitle("Select the type of parameter to create.") - - self.key = parent.key - - layout = QtWidgets.QVBoxLayout() - box = QtWidgets.QGroupBox("Types:") - # Explicitly set the stylesheet to avoid parent classes overriding - box.setStyleSheet( - "QGroupBox {border: 1px solid gray; border-radius: 5px; margin-top: 7px; margin-bottom: 7px; padding: 0px}" - "QGroupBox::title {top:-7 ex;left: 10px; subcontrol-origin: border}" - ) - box_layout = QtWidgets.QVBoxLayout() - self.button_group = QtWidgets.QButtonGroup() - self.button_group.setExclusive(True) - for i, s in enumerate(PARAMETER_STRINGS): - button = QtWidgets.QRadioButton(s) - self.button_group.addButton(button, i) - box_layout.addWidget(button) - # If we have a complete key, pre-select the activity parameter btn. - if all(self.key): - self.button_group.button(2).setChecked(True) - elif self.key[0] != "": - # default to database parameter is we have something. - self.button_group.button(2).setEnabled(False) - self.button_group.button(1).setChecked(True) - else: - # If we don't have a complete key, we can't create an activity parameter - self.button_group.button(2).setEnabled(False) - self.button_group.button(0).setChecked(True) - box.setLayout(box_layout) - layout.addWidget(box) - self.setLayout(layout) - - @property - def selected(self) -> int: - return self.button_group.checkedId() - - -class CompleteParameterPage(QtWidgets.QWizardPage): - def __init__(self, parent): - super().__init__(parent) - self.setTitle("Fill out required values for the parameter") - self.parent = parent - - layout = QtWidgets.QVBoxLayout() - self.setLayout(layout) - box = QtWidgets.QGroupBox("Data:") - box.setStyleSheet( - "QGroupBox {border: 1px solid gray; border-radius: 5px; margin-top: 7px; margin-bottom: 7px; padding: 0px}" - "QGroupBox::title {top:-7 ex;left: 10px; subcontrol-origin: border}" - ) - grid = QtWidgets.QGridLayout() - box.setLayout(grid) - layout.addWidget(box) - - self.key = parent.key - - self.name_label = QtWidgets.QLabel("Name:") - self.name = QtWidgets.QLineEdit() - grid.addWidget(self.name_label, 0, 0) - grid.addWidget(self.name, 0, 1) - self.amount_label = QtWidgets.QLabel("Amount:") - self.amount = QtWidgets.QLineEdit() - locale = QtCore.QLocale(QtCore.QLocale.English) - locale.setNumberOptions(QtCore.QLocale.RejectGroupSeparator) - validator = QtGui.QDoubleValidator() - validator.setLocale(locale) - self.amount.setValidator(validator) - grid.addWidget(self.amount_label, 1, 0) - grid.addWidget(self.amount, 1, 1) - self.database_label = QtWidgets.QLabel("Database:") - self.database = QtWidgets.QComboBox() - grid.addWidget(self.database_label, 2, 0) - grid.addWidget(self.database, 2, 1) - - # Register fields for all possible values - self.registerField("name*", self.name) - self.registerField("amount", self.amount) - self.registerField("database", self.database, "currentText") - - def initializePage(self) -> None: - self.amount.setText("1.0") - if self.parent.selected == 0: - self.name.clear() - self.database.setHidden(True) - self.database_label.setHidden(True) - elif self.parent.selected == 1: - self.name.clear() - self.database.clear() - dbs = list(bd.databases) - self.database.insertItems(0, dbs) - if self.key[0] in dbs: - self.database.setCurrentIndex(dbs.index(self.key[0])) - self.database.setHidden(False) - self.database_label.setHidden(False) - elif self.parent.selected == 2: - self.name.clear() - self.database.setHidden(True) - self.database_label.setHidden(True) diff --git a/activity_browser/app/actions/parameter/parameter_new_automatic.py b/activity_browser/app/actions/parameter/parameter_new_automatic.py deleted file mode 100644 index 4fd69599d..000000000 --- a/activity_browser/app/actions/parameter/parameter_new_automatic.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import List, Tuple - -from peewee import IntegrityError -from qtpy import QtWidgets - -from activity_browser import app -from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from bw2data.parameters import ActivityParameter -from activity_browser.ui.icons import qicons - - -class ParameterNewAutomatic(ABAction): - """ - ABAction for the automatic creation of a new parameter. - - TODO: Remove this action as it is automatic and not user interaction, should be done through e.g. a signal but - TODO: will actually need to be reworked together with the parameters. - """ - - icon = qicons.add - text = "New parameter..." - - @staticmethod - @exception_dialogs - def run(activities: List[tuple | int | bd.Node]): - activities = [refresh_node(x) for x in activities] - - for act in activities: - if act.get("type", "process") not in bd.labels.lci_node_types: - issue = f"Activity must be 'process' type, '{act.get('name')}' is type '{act.get('type')}'." - QtWidgets.QMessageBox.warning( - app.main_window, - "Not allowed", - issue, - QtWidgets.QMessageBox.Ok, - QtWidgets.QMessageBox.Ok, - ) - return - - group = act.id - row = { - "name": "dummy_parameter", - "amount": act.get("amount", 1.0), - "formula": act.get("formula", ""), - "database": act.get("database", ""), - "code": act.get("code", ""), - } - - bd.parameters.new_activity_parameters([row], group) diff --git a/activity_browser/app/actions/parameter/parameter_new_from_parameter.py b/activity_browser/app/actions/parameter/parameter_new_from_parameter.py deleted file mode 100644 index 0ba0757b0..000000000 --- a/activity_browser/app/actions/parameter/parameter_new_from_parameter.py +++ /dev/null @@ -1,61 +0,0 @@ -from ast import literal_eval - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils.utils import Parameter -from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, parameters -from activity_browser.ui.icons import qicons - -from .parameter_new_automatic import ParameterNewAutomatic - - -class ParameterNewFromParameter(ABAction): - """ - ABAction to create a new Parameter from an instantiated Parameter namedtuple - """ - - icon = qicons.add - text = "New parameter..." - - @staticmethod - @exception_dialogs - def run(parameter: Parameter): - if not isinstance(parameter, Parameter) or parameter.param_type is None: - raise ValueError("Parameter must be an instance of Parameter") - - if not parameter.name.isidentifier(): - raise ValueError("Parameter name must be a valid Python identifier") - - # select the right group and instruct the controller to create the parameter there - if parameter.param_type == "project": - ProjectParameter( - name=parameter.name, - formula=parameter.data.get("formula", None), - amount=parameter.amount, - data=parameter.data, - ).save() - elif parameter.param_type == "database": - DatabaseParameter( - database=parameter.group, - name=parameter.name, - formula=parameter.data.get("formula", None), - amount=parameter.amount, - data=parameter.data, - ).save() - elif parameter.param_type == "activity": - mock = ActivityParameter.get_or_none(group=parameter.group) - if mock is None: - ParameterNewAutomatic.run([int(parameter.group)]) - mock = ActivityParameter.get(group=parameter.group) - - ActivityParameter( - group=parameter.group, - database=mock.database, - code=mock.code, - name=parameter.name, - formula=parameter.data.get("formula", None), - amount=parameter.amount, - data=parameter.data, - ).save() - - parameters.recalculate() - diff --git a/activity_browser/app/actions/parameter/parameter_rename.py b/activity_browser/app/actions/parameter/parameter_rename.py deleted file mode 100644 index 8fa768498..000000000 --- a/activity_browser/app/actions/parameter/parameter_rename.py +++ /dev/null @@ -1,48 +0,0 @@ -from qtpy import QtWidgets - -from bw2data.parameters import ParameterBase, parameters - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons -from activity_browser.bwutils.utils import Parameter -from activity_browser.bwutils.commontasks import refresh_parameter - - -class ParameterRename(ABAction): - """ - ABAction to rename an existing parameter. Constructs a dialog for the user in which they choose the new name. If no - name is chosen, or the user cancels: return. Else, instruct the ParameterController to rename the parameter using - the given name. - """ - - icon = qicons.edit - text = "Rename parameter..." - - @staticmethod - @exception_dialogs - def run(parameter: tuple | Parameter | ParameterBase, new_name: str = None): - parameter = refresh_parameter(parameter) - - new_name = new_name or ParameterRename.get_new_name(parameter) - - if not new_name: - return - - if not new_name.isidentifier(): - raise ValueError("Parameter name must be a valid Python identifier") - - getattr(parameters, f"rename_{parameter.param_type}_parameter")( - parameter.to_peewee_model(), new_name, update_dependencies=True - ) - - @staticmethod - def get_new_name(parameter: Parameter): - new_name, ok = QtWidgets.QInputDialog.getText( - app.main_window, - "Rename parameter", - f"Rename parameter '{parameter.name}' to:", - ) - - if ok and new_name: - return new_name diff --git a/activity_browser/app/actions/parameter/parameter_uncertainty_modify.py b/activity_browser/app/actions/parameter/parameter_uncertainty_modify.py deleted file mode 100644 index ea8ba7e87..000000000 --- a/activity_browser/app/actions/parameter/parameter_uncertainty_modify.py +++ /dev/null @@ -1,36 +0,0 @@ -from typing import Any - -import bw2data as bd - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser import app -from activity_browser.ui.dialogs import UncertaintyDialog -from activity_browser.ui.icons import qicons - - -class ParameterUncertaintyModify(ABAction): - """ - ABAction to modify the uncertainty of an existing parameter. - """ - - icon = qicons.edit - text = "Modify parameter uncertainty" - - @staticmethod - @exception_dialogs - def run(parameter: Any, uncertainty_dict: dict=None) -> None: - - if not uncertainty_dict: - initial = parameter.dict.copy() if "uncertainty type" in parameter.dict else None - - ok, uncertainty_dict = UncertaintyDialog.get_uncertainty_dict( - parent=app.main_window, - initial=initial, - ) - - if not ok: - return - - parameter.data.update(uncertainty_dict) - parameter.save() - bd.parameters.recalculate() diff --git a/activity_browser/app/actions/parameter/parameter_uncertainty_remove.py b/activity_browser/app/actions/parameter/parameter_uncertainty_remove.py deleted file mode 100644 index 7210bc8d2..000000000 --- a/activity_browser/app/actions/parameter/parameter_uncertainty_remove.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import Any - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils import uncertainty -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - -class ParameterUncertaintyRemove(ABAction): - """ - ABAction to remove the uncertainty of a parameter. - """ - - icon = qicons.delete - text = "Remove parameter uncertainty" - - @staticmethod - @exception_dialogs - def run(parameter: Any): - parameter.data.update(uncertainty.EMPTY_UNCERTAINTY) - parameter.save() - bd.parameters.recalculate() diff --git a/activity_browser/app/actions/project/project_create_template.py b/activity_browser/app/actions/project/project_create_template.py deleted file mode 100644 index 937767f4b..000000000 --- a/activity_browser/app/actions/project/project_create_template.py +++ /dev/null @@ -1,93 +0,0 @@ -import os -import json -import tarfile -from loguru import logger - -from qtpy import QtWidgets, QtCore -import platformdirs - -from activity_browser import app -from activity_browser.mod import bw2data as bd -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.core.threading import ABThread - - - - -class ProjectCreateTemplate(ABAction): - """ - ABAction to export the current project. Prompts the user to return a save-file location. And then start a thread to - package the project and save it there. Saving code copied from bw2data.backup. - """ - icon = app.application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) - text = "Create template from project" - tool_tip = "Export project to file" - - @staticmethod - @exception_dialogs - def run(project_name: str = None, parent=None): - """Export the current project to a folder chosen by the user.""" - if project_name is None: - project_name = bd.projects.current - - # get target path from the user - template_name, ok = QtWidgets.QInputDialog.getText( - parent if parent else app.main_window, - "Create template from project", - f"Creating new template from project ({project_name}):" - + " " * 10, - ) - - if not ok or not template_name: - return - - template_file = template_name.strip() + ".tar.gz" - base_dir = platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="ActivityBrowser") - template_path = os.path.join(base_dir, "templates", template_file) - - os.makedirs(os.path.join(base_dir, "templates"), exist_ok=True) - - if os.path.exists(template_path): - QtWidgets.QMessageBox.information( - app.main_window, - "Not possible.", - "A template with this name already exists.", - ) - return - - # setup dialog - progress = QtWidgets.QProgressDialog( - parent=parent if parent else app.main_window, - labelText="Creating template", - maximum=0 - ) - progress.setCancelButton(None) - progress.setWindowTitle("Creating template") - progress.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False) - progress.setWindowFlag(QtCore.Qt.WindowCloseButtonHint, False) - progress.findChild(QtWidgets.QProgressBar).setTextVisible(False) - progress.resize(400, 100) - progress.show() - - thread = TemplateThread(app.application) - setattr(thread, "save_path", template_path) - setattr(thread, "project_name", project_name) - thread.finished.connect(lambda: progress.deleteLater()) - thread.start() - - -class TemplateThread(ABThread): - save_path: str - project_name: str - - def run_safely(self): - project_dir = str(os.path.join(bd.projects._base_data_dir, bd.utils.safe_filename(self.project_name))) - - with open(os.path.join(project_dir, ".project-name.json"), "w") as f: - json.dump({"name": self.project_name}, f) - - logger.info("Creating project template - this could take a few minutes...") - with tarfile.open(self.save_path, "w:gz") as tar: - tar.add(project_dir, arcname=bd.utils.safe_filename(self.project_name)) - - logger.info(f"Created template from `{self.project_name}`.") diff --git a/activity_browser/app/actions/project/project_delete.py b/activity_browser/app/actions/project/project_delete.py deleted file mode 100644 index 3552f3825..000000000 --- a/activity_browser/app/actions/project/project_delete.py +++ /dev/null @@ -1,134 +0,0 @@ -import shutil - -from qtpy import QtWidgets - -import bw2data as bd -from bw2data.project import ProjectDataset -from bw2data.utils import safe_filename - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons - -from .project_switch import ProjectSwitch - - -class ProjectDelete(ABAction): - """ - Deletes the specified projects or the currently active project if no project names are provided. - - This method handles the deletion of Brightway2 projects. It ensures that the startup project - cannot be deleted, prompts the user for confirmation, and optionally deletes the project - directories from the hard disk. - - Args: - project_names (list of str, optional): A list of project names to delete. If None, the - currently active project is selected. - - Steps: - - If no project names are provided, use the currently active project. - - Return immediately if the project list is empty. - - Prevent deletion of the startup project and notify the user if attempted. - - Open a confirmation dialog for the user to approve the deletion. - - If the user cancels, return without deleting. - - If the currently active project is being deleted, switch to the startup project. - - Delete the specified projects, optionally removing their directories from the hard disk. - - Notify the user of successful deletion. - - Raises: - None - """ - - icon = qicons.delete - text = "Delete this project" - tool_tip = "Delete the project" - - @staticmethod - @exception_dialogs - def run(project_names: list[str] = None): - if project_names is None: - # get the current project - project_names = [bd.projects.current] - - if len(project_names) == 0: - return - - # if it's the startup project: reject deletion and inform user - if app.settings["startup"]["startup_project"] in project_names: - QtWidgets.QMessageBox.information( - app.main_window, - "Not possible", - "Can't delete the startup project. Please select another startup project in the settings first.", - ) - return - - # open a delete dialog for the user to confirm, return if user rejects - delete_dialog = ProjectDeletionDialog(project_names, app.main_window) - if delete_dialog.exec_() != ProjectDeletionDialog.Accepted: - return - - # try to delete the project, delete directory if user specified so - if bd.projects.current in project_names: - ProjectSwitch.run(settings.ab_settings.startup_project) - - for project in project_names: - ProjectDelete.delete_project(project, delete_dialog.deletion_warning_checked()) - - # inform the user of successful deletion - QtWidgets.QMessageBox.information( - app.main_window, "Project(s) deleted", "Project(s) successfully deleted" - ) - - @staticmethod - def delete_project(name: str, delete_dir: bool): - - ds = ProjectDataset.get(ProjectDataset.name == name) - - if delete_dir: - dir_path = bd.projects._base_data_dir / safe_filename(name, full=ds.full_hash) - assert dir_path.is_dir(), "Can't find project directory" - shutil.rmtree(dir_path) - - ds.delete_instance() - - # THIS SHOULD NOT HAPPEN HERE BUT bw2data HAS NO SIGNALS FOR PROJECT DELETION - app.signals.project.deleted.emit(name) - - -class ProjectDeletionDialog(QtWidgets.QDialog): - - def __init__(self, projects: list[str], parent=None): - super().__init__(parent) - - self.title = "Confirm project deletion" - - if len(projects) == 1: - self.label = QtWidgets.QLabel( - f"Final confirmation to remove project: {projects[0]}.\n" - + "Warning: Non reversible process!" - ) - else: - self.label = QtWidgets.QLabel( - f"Final confirmation to remove {len(projects)} projects.\n" - + "Warning: Non reversible process!" - ) - self.check = QtWidgets.QVBoxLayout() - self.hd_check = QtWidgets.QCheckBox(f"Remove from the hard disk") - self.hd_check.setChecked(True) - - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - self.setWindowTitle(self.title) - self.layout = QtWidgets.QVBoxLayout() - self.layout.addWidget(self.label) - self.layout.addWidget(self.hd_check) - self.layout.addWidget(self.buttons) - - self.setLayout(self.layout) - - def deletion_warning_checked(self): - return self.hd_check.isChecked() diff --git a/activity_browser/app/actions/project/project_duplicate.py b/activity_browser/app/actions/project/project_duplicate.py deleted file mode 100644 index c6701491b..000000000 --- a/activity_browser/app/actions/project/project_duplicate.py +++ /dev/null @@ -1,65 +0,0 @@ -from qtpy import QtWidgets - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - -from .project_switch import ProjectSwitch - - -class ProjectDuplicate(ABAction): - """ - Duplicate the current project to a new name. - - This method prompts the user to input a new name for duplicating the current project. - It performs validation to ensure the new name is not empty and does not already exist. - If the provided name is valid, the current project is duplicated to the new name, and - the application switches to the newly created project. - - Args: - name (str, optional): The name of the current project to duplicate. Defaults to the - currently active project. - - Steps: - - If no name is provided, use the current project name. - - Prompt the user for a new project name. - - Return if the user cancels or provides an empty name. - - Check if the new name already exists and show an error message if it does. - - If the provided name is not the current project, set it as the current project. - - Duplicate the project to the new name without switching to it. - - Switch to the newly created project using the `ProjectSwitch` action. - """ - - icon = qicons.copy - text = "Duplicate this project" - tool_tip = "Duplicate the project" - - @staticmethod - @exception_dialogs - def run(name: str = None): - if name is None: - name = bd.projects.current - - new_name, ok = QtWidgets.QInputDialog.getText( - app.main_window, - "Duplicate current project", - f"Duplicate project ({name}) to new name:" - + " " * 10, - ) - - if not ok or not new_name: - return - - if new_name in bd.projects: - QtWidgets.QMessageBox.information( - app.main_window, - "Not possible.", - "A project with this name already exists.", - ) - return - - if name != bd.projects.current: - bd.projects.set_current(name, update=False) - bd.projects.copy_project(new_name, switch=False) # don't switch because it will auto-update bw2 projects - ProjectSwitch.run(new_name) # switch using the action instead diff --git a/activity_browser/app/actions/project/project_export.py b/activity_browser/app/actions/project/project_export.py deleted file mode 100644 index 8b9a83f28..000000000 --- a/activity_browser/app/actions/project/project_export.py +++ /dev/null @@ -1,81 +0,0 @@ -import os -import json -import tarfile -from loguru import logger - -from qtpy import QtWidgets, QtCore - -import bw2data as bd -from bw2data.project import ProjectDataset - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.core.threading import ABThread - - - - -class ProjectExport(ABAction): - """ - ABAction to export the current project. Prompts the user to return a save-file location. And then start a thread to - package the project and save it there. Saving code copied from bw2data.backup. - """ - icon = app.application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) - text = "&Export this project..." - tool_tip = "Export project to file" - - @staticmethod - @exception_dialogs - def run(project_name: str = None): - """Export the current project to a folder chosen by the user.""" - if project_name is None: - project_name = bd.projects.current - - # get target path from the user - save_path, save_type = QtWidgets.QFileDialog.getSaveFileName( - parent=app.main_window, - caption="Choose where", - dir=os.path.expanduser(f"~/{project_name}.tar.gz"), - filter="Tar-file (*.tar.gz)" - ) - - if not save_path: return - - # setup dialog - progress = QtWidgets.QProgressDialog( - parent=app.main_window, - labelText="Exporting project", - maximum=0 - ) - progress.setCancelButton(None) - progress.setWindowTitle("Exporting project") - progress.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False) - progress.setWindowFlag(QtCore.Qt.WindowCloseButtonHint, False) - progress.findChild(QtWidgets.QProgressBar).setTextVisible(False) - progress.resize(400, 100) - progress.show() - - thread = ExportThread(app.application) - setattr(thread, "save_path", save_path) - setattr(thread, "project_name", project_name) - thread.finished.connect(lambda: progress.deleteLater()) - thread.start() - - -class ExportThread(ABThread): - save_path: str - project_name: str - - def run_safely(self): - ds = ProjectDataset.get(ProjectDataset.name == self.project_name) - project_folder_name = bd.utils.safe_filename(self.project_name, full=ds.full_hash) - project_dir = os.path.join(bd.projects._base_data_dir, project_folder_name) - - with open(os.path.join(project_dir, ".project-name.json"), "w") as f: - json.dump({"name": self.project_name}, f) - - logger.info("Creating project backup archive - this could take a few minutes...") - with tarfile.open(self.save_path, "w:gz") as tar: - tar.add(project_dir, arcname=bd.utils.safe_filename(self.project_name)) - - logger.info(f"Project `{self.project_name}` exported.") diff --git a/activity_browser/app/actions/project/project_import.py b/activity_browser/app/actions/project/project_import.py deleted file mode 100644 index 002797b9a..000000000 --- a/activity_browser/app/actions/project/project_import.py +++ /dev/null @@ -1,116 +0,0 @@ -import codecs -import json -import tarfile -from loguru import logger - -import bw2data as bd -from qtpy import QtWidgets, QtCore -from bw2io import backup - -from activity_browser import app -from activity_browser.mod import bw2data as bd -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons -from activity_browser.ui.core.threading import ABThread - - - - -class ProjectImport(ABAction): - """ - ABAction to import a new project. Prompts the user to select a file. Imports the project name from the file as a - suggestion. Prompts user to either accept the name or change it. If the name already exists, try again. Else, - perform the import in a separate thread and show a progress dialog until it is finished. Finally, move to the newly - imported project. - """ - icon = qicons.import_db - text = "&Import a project..." - tool_tip = "Import project from a file" - - @classmethod - @exception_dialogs - def run(cls): - """Import a project into AB based on file chosen by user.""" - - # get the path from the user - path, _ = QtWidgets.QFileDialog.getOpenFileName( - parent=app.main_window, - caption='Choose project file to import', - filter='Tar archive (*.tar.gz);; All files (*.*)' - ) - if not path: return - - # create a name suggestion based on the file name - suggestion = cls.get_project_name(path) - - # get a new project name from the user: - while True: - project_name, _ = QtWidgets.QInputDialog.getText( - app.main_window, - 'Choose project name', - 'Choose a name for your project', - text=suggestion - ) - - if not project_name: return - - if project_name in bd.projects: - # this name already exists, inform user and ask again. - QtWidgets.QMessageBox.information( - app.main_window, - "Not possible.", - "A project with this name already exists." - ) - else: break - - # setup dialog - progress = QtWidgets.QProgressDialog( - parent=app.main_window, - labelText="Importing project", - maximum=0 - ) - progress.setCancelButton(None) - progress.setWindowTitle("Importing project") - progress.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False) - progress.setWindowFlag(QtCore.Qt.WindowCloseButtonHint, False) - progress.findChild(QtWidgets.QProgressBar).setTextVisible(False) - progress.resize(400, 100) - progress.show() - - # setup the import - thread = ImportThread(app.application) - setattr(thread, "path", path) - setattr(thread, "project_name", project_name) - - thread.finished.connect(lambda: progress.deleteLater()) - thread.finished.connect(lambda: bd.projects.set_current(project_name, update=False)) - - # start the import - thread.start() - - @staticmethod - def get_project_name(fp): - reader = codecs.getreader("utf-8") - # See https://stackoverflow.com/questions/68997850/python-readlines-with-tar-file-gives-streamerror-seeking-backwards-is-not-al/68998071#68998071 - with tarfile.open(fp, "r:gz") as tar: - for member in tar: - if member.name[-17:] == "project-name.json": - return json.load(reader(tar.extractfile(member)))["name"] - return "" - - -class ImportThread(ABThread): - - def run_safely(self): - logger.debug('Starting project import:' - f'\nPATH: {self.path}' - f'\nNAME: {self.project_name}') - backup.restore_project_directory(fp=self.path, project_name=self.project_name) - - # fix wrong hashing - ds = bd.project.ProjectDataset.get(name=self.project_name) - ds.full_hash = False - ds.save() - - logger.info(f"Project `{self.project_name}` imported.") - diff --git a/activity_browser/app/actions/project/project_local_import.py b/activity_browser/app/actions/project/project_local_import.py deleted file mode 100644 index b4029806f..000000000 --- a/activity_browser/app/actions/project/project_local_import.py +++ /dev/null @@ -1,278 +0,0 @@ -import json -from tarfile import open as tar_open, TarFile, TarError -from loguru import logger - -from qtpy import QtWidgets, QtCore -from bw2io import restore_project_directory - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui import icons, widgets - - - - -class ProjectLocalImportWindow(QtWidgets.QDialog): - - MAX_PROJECT_NAME_JSON_SIZE = 1024 - PROJECT_FILE = ".project-name.json" - - def __init__(self): - super().__init__() - self.setWindowTitle("Import project from file") - self.setSizePolicy( - QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding - ) - ) - - layout = QtWidgets.QVBoxLayout() - dialog_spacing = 8 - - file_chooser_layout = QtWidgets.QHBoxLayout() - file_chooser_layout.setAlignment(QtCore.Qt.AlignLeft) - tarball_label = QtWidgets.QLabel("Project file:") - self._selected_file_edit = QtWidgets.QLineEdit() - self._selected_file_edit.setMinimumWidth(300) - self._selected_file_edit.textChanged.connect(self._load_project_name) - self._browse_button = QtWidgets.QPushButton("Browse") - self._browse_button.clicked.connect(self._handle_browse_clicked) - file_chooser_layout.addWidget(tarball_label) - file_chooser_layout.addWidget(self._selected_file_edit) - file_chooser_layout.addWidget(self._browse_button) - - layout.addLayout(file_chooser_layout) - - layout.addSpacing(dialog_spacing) - - project_name_layout = QtWidgets.QHBoxLayout() - project_name_layout.setAlignment(QtCore.Qt.AlignLeft) - project_name_layout.addWidget(widgets.ABLabel.demiBold("Project name:")) - self.project_name = QtWidgets.QLineEdit() - self.project_name.setText("") - self.project_name.textChanged.connect(self._handle_project_name_changed) - project_name_layout.addWidget(self.project_name) - layout.addLayout(project_name_layout) - - self._overwrite_checkbox = QtWidgets.QCheckBox("Overwrite existing project") - self._overwrite_checkbox.clicked.connect(self._handle_overwrite_clicked) - layout.addWidget(self._overwrite_checkbox) - - self._activate_project_checkbox = QtWidgets.QCheckBox("Activate project after import") - self._activate_project_checkbox.setChecked(True) - layout.addWidget(self._activate_project_checkbox) - - import_button_layout = QtWidgets.QHBoxLayout() - self.import_button = QtWidgets.QPushButton("Create project") - import_button_layout.addWidget(self.import_button) - self.import_button.clicked.connect(self._import_project) - layout.addLayout(import_button_layout) - self._message_label = QtWidgets.QLabel() - - layout.addWidget(self._message_label) - - self.setLayout(layout) - self._last_url = "" - self._loaded_project_name = "" - self._reset_dialog() - self._message_label.setText("Select a project file") - - def _reset_dialog(self): - self.project_name.setEnabled(False) - self.project_name.setPlaceholderText("") - self._overwrite_checkbox.setEnabled(False) - self._overwrite_checkbox.setChecked(False) - self._activate_project_checkbox.setEnabled(False) - self.import_button.setEnabled(False) - self._message_label.setText("") - - def _enable_ui(self): - self.project_name.setEnabled(True) - self.project_name.setPlaceholderText("") - self._activate_project_checkbox.setEnabled(True) - self._message_label.setText("") - - def _handle_browse_clicked(self): - """Open a system file dialog and allow the user to select a file""" - file = QtWidgets.QFileDialog().getOpenFileName( - self, - "Select archive file", - filter = "Tar GZ (*.tar.gz)" - )[0] - # The returned value is None on Cancel - if file: - self._selected_file_edit.setText(QtCore.QDir.toNativeSeparators(file)) - self._load_project_name() - - def _decode_project_name(self, tar: TarFile): - """ - Get the list of files from the TarFile, and decode the name - from the .project-name.json. - - Updates the UI with error messages if it fails. - """ - # all files in the archive - name_list = tar.getnames() - # list of files, where the path contains ".project-name.json" - project_name_files = [name for name in name_list if self.PROJECT_FILE in name] - if len(project_name_files) == 0: - self._message_label.setText( - f"No '{self.PROJECT_FILE}' file found in project file" - ) - return - if len(project_name_files) > 1: - self._message_label.setText( - f"More than one '{self.PROJECT_FILE}' file found in project file" - ) - return - # choose the first one, we expect to have only one - project_name_file = project_name_files[0] - # get TarInfo for it - tar_info_project_name_file = tar.getmember(project_name_file) - # prevent too big files from being extracted - if tar_info_project_name_file.size > self.MAX_PROJECT_NAME_JSON_SIZE: - self._message_label.setText( - f"Size of '{self.PROJECT_FILE}' file is too " - f"big: {tar_info_project_name_file.size}" - ) - return - # get extracter BufferedReader - if extracter := tar.extractfile(project_name_file): - try: - # JSON should have a single string value with the key "name" - project_name = json.loads(extracter.read())["name"] - except: - self._message_label.setText( - "Failed to decode project name" - ) - return - if project_name == "": - self._message_label.setText("Decoded project name is empty") - return - self._enable_ui() - self._loaded_project_name = project_name - self.project_name.setPlaceholderText(self._loaded_project_name) - self._check_project_already_exists() - - def _load_project_name(self): - """Exception handling for the project name decoding.""" - try: - self._reset_dialog() - archive = self._selected_file_edit.text() - tar = tar_open(archive, "r:gz") - except FileNotFoundError: - self._message_label.setText("Project file not found") - except TarError: - self._message_label.setText("Error opening project file") - except (ValueError, OSError): - self._message_label.setText("Select a project file") - else: - try: - with tar: - self._decode_project_name(tar) - except TarError: - self._message_label.setText("Error opening project file") - - def _selected_project_name(self) -> str: - """The name of the project as decoded from the tarball""" - return self._loaded_project_name - - def _project_name(self) -> str: - """Return the user typed project name or, if empty, the loaded one.""" - if self.project_name.text() == "": - return self._selected_project_name() - return self.project_name.text() - - def _handle_project_name_changed(self): - """Trigger duplicate project name check""" - self._check_project_already_exists() - - def _unique_project_update(self): - """ - Update the UI when the entered project name is unique. - """ - self.import_button.setEnabled(True) - self._message_label.setText("") - - def _duplicate_project_checkbox_update(self): - """ - Update the UI when the overwrite checkbox state changes and the - project name is not unique. - - Use the actual state of the checkbox, because it is not - called only from the checkbox click event. - """ - if self._overwrite_checkbox.isChecked(): - self.import_button.setEnabled(True) - self._message_label.setText("") - else: - self.import_button.setEnabled(False) - self._message_label.setText("Project name already exists") - - def _handle_overwrite_clicked(self): - self._duplicate_project_checkbox_update() - - def _check_project_already_exists(self): - """ - Update the overwrite checkbox and import button based on the project name. - - If the project already exists, it can only be imported with the - overwrite flag set. To make sure the user does not import it accidentaly, - the flag is reset every time the selected project or the project name changes. - """ - self._overwrite_checkbox.setChecked(False) - if self._project_name() in bd.projects: - self._overwrite_checkbox.setEnabled(True) - self._duplicate_project_checkbox_update() - else: - self._overwrite_checkbox.setEnabled(False) - self._unique_project_update() - - def _import_project(self): - """ - Import the selected project with the new name. - It is checked with the UI flow, that there is a tarball loaded, - and a unique name provided or the overwrite flag is set. - """ - original_name = self._selected_project_name() - new_name = self._project_name() - if original_name and new_name: - logger.info(f"Importing project with name {new_name} " - f"(original name {original_name})") - self.import_button.setText("Creating project...") - self.import_button.setEnabled(False) - self.repaint() - self.setCursor(QtCore.Qt.WaitCursor) - - restore_project_directory( - self._selected_file_edit.text(), - new_name, - overwrite_existing=self._overwrite_checkbox.isChecked() - ) - if self._activate_project_checkbox.isChecked(): - bd.projects.set_current(new_name, update=False) - self.setCursor(QtCore.Qt.ArrowCursor) - self.accept() - else: - logger.error( - f"Project name ({new_name}) or " - f"import name ({original_name}) is not valid." - ) - - -class ProjectLocalImport(ABAction): - """ - ABAction to download a project file from a remote server. - Allows for customization of server URL, created project name, and whether or not to overwrite existing projects. - """ - - icon = icons.qicons.import_db - text = "Import local project" - tool_tip = "Import a project file from a remote server" - - @staticmethod - @exception_dialogs - def run(): - window = ProjectLocalImportWindow() - window.adjustSize() - window.exec_() diff --git a/activity_browser/app/actions/project/project_manager_open.py b/activity_browser/app/actions/project/project_manager_open.py deleted file mode 100644 index 5bada520f..000000000 --- a/activity_browser/app/actions/project/project_manager_open.py +++ /dev/null @@ -1,26 +0,0 @@ -from qtpy import QtCore - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs - -from activity_browser.ui.icons import qicons - - -class ProjectManagerOpen(ABAction): - """ - ABAction to delete a database from the project. Asks the user for confirmation. If confirmed, instructs the - DatabaseController to delete the database in question. - """ - - icon = qicons.delete - text = "Open project manager" - - @staticmethod - @exception_dialogs - def run(): - from activity_browser.app.panes import ProjectManagerPane - - project_manager = ProjectManagerPane(app.main_window) - app.main_window.addDockWidget( - QtCore.Qt.LeftDockWidgetArea, - project_manager.getDockWidget(app.main_window)) diff --git a/activity_browser/app/actions/project/project_migrate25.py b/activity_browser/app/actions/project/project_migrate25.py deleted file mode 100644 index 209bd4364..000000000 --- a/activity_browser/app/actions/project/project_migrate25.py +++ /dev/null @@ -1,164 +0,0 @@ -from tqdm import tqdm -from loguru import logger -from qtpy import QtWidgets, QtGui, QtCore - -import bw2data as bd -import pandas as pd - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.icons import qicons -from activity_browser.ui.core.threading import ABThread - - - - -class ProjectMigrate25(ABAction): - """ - ABAction to duplicate a project. Asks the user for a new name. Returns if no name is given, the user cancels, or - when the name is already in use by another project. Else, instructs the ProjectController to duplicate the current - project to the new name. - """ - - icon = qicons.copy - text = "Migrate project" - tool_tip = "Migrate the project to bw25" - - @staticmethod - @exception_dialogs - def run(name: str = None): - if name is None: - name = bd.projects.current - - dialog = MigrateDialog(name, app.main_window) - dialog.exec_() - - if dialog.result() == dialog.DialogCode.Rejected: - return - - if name != bd.projects.current: - bd.projects.set_current(name, update=False) - - # setup dialog - progress = QtWidgets.QProgressDialog( - parent=app.main_window, - labelText="Migrating project, this may take a while...", - maximum=0 - ) - progress.setCancelButton(None) - progress.setWindowTitle("Migrating project to Brightway25") - progress.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False) - progress.setWindowFlag(QtCore.Qt.WindowCloseButtonHint, False) - progress.findChild(QtWidgets.QProgressBar).setTextVisible(False) - progress.resize(400, 100) - progress.show() - - thread = MigrateThread(app.application) - thread.finished.connect(lambda: progress.deleteLater()) - thread.start() - thread.connect_progress_dialog(progress) - - -class MigrateDialog(QtWidgets.QDialog): - def __init__(self, project_name: str, parent=None): - from .project_export import ProjectExport - - super().__init__(parent) - self.setWindowTitle("Migrate project") - label = QtWidgets.QLabel(f"Migrate project ({project_name}) from legacy to Brightway25. This cannot be undone.") - - cancel = QtWidgets.QPushButton("Cancel") - migrate = QtWidgets.QPushButton("Migrate") - backup = ProjectExport.get_QButton(project_name) - backup.setText("Back-up project") - backup.setIcon(QtGui.QIcon()) - - cancel.clicked.connect(self.reject) - migrate.clicked.connect(self.accept) - - button_layout = QtWidgets.QHBoxLayout() - button_layout.addWidget(backup) - button_layout.addStretch() - button_layout.addWidget(cancel) - button_layout.addWidget(migrate) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(label) - layout.addLayout(button_layout) - - self.setLayout(layout) - - -class MigrateThread(ABThread): - def run_safely(self): - self.pre_process_methods() - - logger.info("Updating and processing all datasets in the project") - bd.projects.set_current(bd.projects.current) - - for db_name in bd.databases: - self.update_database_activity_types(db_name) - - # set the bw25 flag in the project dataset - bd.projects.dataset.data["25"] = True - bd.projects.dataset.save() - - # reloading project to ensure all changes are applied - bd.projects.set_current(bd.projects.current) - - @classmethod - def pre_process_methods(cls): - logger.info("Pre-processing methods for migration to bw25") - data = {m: bd.Method(m).load() for m in bd.methods} - df = pd.DataFrame([(k, v[0][0], v[0][1], v[1]) - for k, values in data.items() for v in values - if isinstance(v[0], tuple) and len(v) == 2 and len(v[0]) == 2], - columns=["method", "database", "code", "value"]) - - df = df.merge(app.metadata.dataframe["id"], left_on=["database", "code"], right_index=True) - - app.signals.method.blockSignals(True) - app.signals.meta.blockSignals(True) - - for name in tqdm(df["method"].unique(), desc="Pre-processing methods", unit="method", total=len(df["method"].unique())): - method_df = df[df["method"] == name][["id", "value"]] - method_list = list(method_df.itertuples(index=False, name=None)) - bd.Method(name).write(method_list, process=False) - - app.signals.method.blockSignals(False) - app.signals.meta.blockSignals(False) - - return - - @classmethod - def update_database_activity_types(cls, db_name: str): - database = bd.Database(db_name) - write = False - - if not isinstance(database, bd.backends.SQLiteBackend): - return - - logger.info(f"Updating activity types in {db_name}") - raw = database.load() - - for key, ds in tqdm(raw.items(), desc=f"Updating activity types in {db_name}", unit="activity", total=len(raw)): - if cls.activity_is_processwithreferenceproduct(ds): - write = True - ds["type"] = "processwithreferenceproduct" - - if write: - database.write(raw) - - @staticmethod - def activity_is_processwithreferenceproduct(ds: dict) -> bool: - production = [exc for exc in ds.get("exchanges", []) if exc.get("type") == "production"] - return ( - ds.get("type") in ["process", "processwithreferenceproduct"] and - ( - len(production) == 0 or - production[0].get("input") == (ds["database"], ds["code"]) - ) - ) - - - diff --git a/activity_browser/app/actions/project/project_new.py b/activity_browser/app/actions/project/project_new.py deleted file mode 100644 index 52ad7d26a..000000000 --- a/activity_browser/app/actions/project/project_new.py +++ /dev/null @@ -1,49 +0,0 @@ -from qtpy import QtWidgets - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui.icons import qicons - - -class ProjectNew(ABAction): - """ - Prompts the user to create a new project by entering a name. If the name is valid and not already in use, - a new project is created and set as the current project. - - Steps: - - Open a dialog to get the new project name from the user. - - Return if the user cancels or provides an empty name. - - Check if the name already exists and show an error message if it does. - - Create a new project with the given name and set it as the current project. - - Raises: - None - """ - - icon = qicons.add - text = "New project" - tool_tip = "Make a new project" - - @staticmethod - @exception_dialogs - def run(): - name, ok = QtWidgets.QInputDialog.getText( - app.main_window, - "Create new project", - "Name of new project:" + " " * 25, - ) - - if not ok or not name: - return - - if name in bd.projects: - QtWidgets.QMessageBox.information( - app.main_window, - "Not possible.", - "A project with this name already exists.", - ) - return - - bd.projects.create_project(name) - bd.projects.set_current(name, update=False) diff --git a/activity_browser/app/actions/project/project_new_remote.py b/activity_browser/app/actions/project/project_new_remote.py deleted file mode 100644 index d61923592..000000000 --- a/activity_browser/app/actions/project/project_new_remote.py +++ /dev/null @@ -1,65 +0,0 @@ -from qtpy import QtWidgets - -import bw2data as bd - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod.bw2io import remote -from activity_browser.ui.icons import qicons -from activity_browser.ui.core.threading import ABThread - -from .project_switch import ProjectSwitch - - -class ProjectNewRemote(ABAction): - """ - ABAction to create a new project from a remote template. - """ - - icon = qicons.add - text = "New project from remote" - tool_tip = "Make a new project from remote template" - - @staticmethod - @exception_dialogs - def run(project_key: str): - name, ok = QtWidgets.QInputDialog.getText( - app.main_window, - "Create project from remote", - "Name of new project:" + " " * 25, - ) - - if not ok or not name: - return - - if name in bd.projects: - QtWidgets.QMessageBox.information( - app.main_window, - "Not possible.", - "A project with this name already exists.", - ) - return - - thread = InstallThread(app.application) - thread.start(project_key, name) - - dialog = MigrateDialog(app.main_window) - dialog.show() - - thread.finished.connect(dialog.close) - thread.finished.connect(lambda: ProjectSwitch.run(name)) - - -class MigrateDialog(QtWidgets.QProgressDialog): - def __init__(self, parent): - super().__init__(parent) - self.setWindowTitle("Installing project") - self.setLabelText("Restoring project from template, this may take a while...") - self.setRange(0, 0) - self.setCancelButton(None) - - -class InstallThread(ABThread): - def run_safely(self, project_key: str, name: str): - remote.install_project(project_key, name) - diff --git a/activity_browser/app/actions/project/project_new_template.py b/activity_browser/app/actions/project/project_new_template.py deleted file mode 100644 index 936d2d8db..000000000 --- a/activity_browser/app/actions/project/project_new_template.py +++ /dev/null @@ -1,87 +0,0 @@ -from qtpy import QtWidgets, QtCore -from loguru import logger - -import bw2data as bd -from bw2io import backup - -from activity_browser import app -from activity_browser.bwutils.commontasks import get_templates -from activity_browser.app.actions.base import ABAction, exception_dialogs - -from activity_browser.ui.core.threading import ABThread -from activity_browser.ui.icons import qicons - - - - -class ProjectNewFromTemplate(ABAction): - """ - ABAction to create a new project from a remote template. - """ - - icon = qicons.add - text = "New project from remote" - tool_tip = "Make a new project from remote template" - - @staticmethod - @exception_dialogs - def run(template_key: str): - - if template_key not in get_templates(): - raise ValueError(f"Template key '{template_key}' not found.") - - name, ok = QtWidgets.QInputDialog.getText( - app.main_window, - "Create project from template", - "Name of new project:" + " " * 25, - ) - - if not ok or not name: - return - - if name in bd.projects: - QtWidgets.QMessageBox.information( - app.main_window, - "Not possible.", - "A project with this name already exists.", - ) - return - - # setup dialog - progress = QtWidgets.QProgressDialog( - parent=app.main_window, - labelText="Creating project from template", - maximum=0 - ) - progress.setCancelButton(None) - progress.setWindowTitle("Creating project from template") - progress.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False) - progress.setWindowFlag(QtCore.Qt.WindowCloseButtonHint, False) - progress.findChild(QtWidgets.QProgressBar).setTextVisible(False) - progress.resize(400, 100) - progress.show() - - # setup the import - thread = ImportThread(app.application) - setattr(thread, "path", get_templates()[template_key]) - setattr(thread, "project_name", name) - - thread.finished.connect(lambda: progress.deleteLater()) - thread.finished.connect(lambda: bd.projects.set_current(name, update=False)) - - # start the import - thread.start() - - -class ImportThread(ABThread): - path: str - project_name: str - - def run_safely(self): - logger.debug('Creating project from template:' - f'\nPATH: {self.path}' - f'\nNAME: {self.project_name}') - backup.restore_project_directory(fp=self.path, project_name=self.project_name) - logger.info(f"Project `{self.project_name}` created.") - - diff --git a/activity_browser/app/actions/project/project_remote_import.py b/activity_browser/app/actions/project/project_remote_import.py deleted file mode 100644 index 924b8cc16..000000000 --- a/activity_browser/app/actions/project/project_remote_import.py +++ /dev/null @@ -1,318 +0,0 @@ -from typing import Any -from urllib.parse import urljoin -from loguru import logger - -from qtpy import QtWidgets, QtCore - -from bw2io import install_project -import requests - -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.mod import bw2data as bd -from activity_browser.ui import icons, widgets - - - - -class CatalogueModel(QtCore.QAbstractTableModel): - def __init__(self): - super().__init__() - self._data = [] - self._sorted = [key for key in self._data] - - def populate(self, data: dict) -> None: - self._data = data - self._sorted = [key for key in self._data] - - def data(self, index: int, role: int): - if role == QtCore.Qt.DisplayRole: - return self._sorted[index.row()] - elif role == QtCore.Qt.ToolTipRole: - return self._data[self._sorted[index.row()]] - - def rowCount(self, index: int) -> int: - return len(self._data) - - def columnCount(self, index: int) -> int: - return 1 - - def headerData(self, section:int, orientation:QtCore.Qt.Orientation, role: int=QtCore.Qt.DisplayRole) -> Any: - if orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole: - return "Available projects" - return None - - -class CatalogueTable(QtWidgets.QTableView): - def __init__(self, parent=None): - super().__init__(parent) - self.setVerticalScrollMode(QtWidgets.QTableView.ScrollPerPixel) - self.setHorizontalScrollMode(QtWidgets.QTableView.ScrollPerPixel) - self.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Expanding) - - self.setWordWrap(True) - self.setAlternatingRowColors(True) - self.setSortingEnabled(False) - - self.model = CatalogueModel() - self.setModel(self.model) - - self.horizontalHeader().setStretchLastSection(True) - self.horizontalHeader().setHighlightSections(False) - self.horizontalHeader().setDefaultAlignment(QtCore.Qt.AlignLeft) - self.verticalHeader().setVisible(False) - self.setTabKeyNavigation(False) - self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection) - - self.table_name = "Available projects" - # Make sure the selected projects is still visible after the focus leaves the table - self.setStyleSheet("QTableView:!active {selection-background-color: lightgray;}") - - def populate(self, data: dict) -> None: - self.model.populate(data) - self.model.layoutChanged.emit() - - -class ProjectRemoteImportWindow(QtWidgets.QDialog): - def __init__(self): - super().__init__() - self.setWindowTitle("Import project from remote server") - self.setSizePolicy( - QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding - ) - ) - - layout = QtWidgets.QVBoxLayout() - dialog_spacing = 8 - - remote_url_layout = QtWidgets.QHBoxLayout() - remote_url_layout.setAlignment(QtCore.Qt.AlignLeft) - remote_url_layout.addWidget(widgets.ABLabel.demiBold("Remote URL:")) - self.remote_url_path = QtWidgets.QLineEdit() - self.remote_url_path.setText("https://files.brightway.dev/") - self.remote_url_path.textChanged.connect(self._handle_url_changed) - remote_url_layout.addWidget(self.remote_url_path) - layout.addLayout(remote_url_layout) - - remote_catalogue_layout = QtWidgets.QHBoxLayout() - remote_catalogue_layout.setAlignment(QtCore.Qt.AlignLeft) - remote_catalogue_layout.addWidget(widgets.ABLabel.demiBold("Catalogue file:")) - self.remote_catalogue = QtWidgets.QLineEdit() - self.remote_catalogue.setText("projects-config.json") - self.remote_catalogue.textChanged.connect(self._handle_url_changed) - remote_catalogue_layout.addWidget(self.remote_catalogue) - layout.addLayout(remote_catalogue_layout) - layout.addSpacing(dialog_spacing) - - refresh_button_layout = QtWidgets.QHBoxLayout() - self.refresh_button = QtWidgets.QPushButton("Download catalogue") - refresh_button_layout.addWidget(self.refresh_button) - self.refresh_button.clicked.connect(self._populate_table) - layout.addLayout(refresh_button_layout) - layout.addSpacing(dialog_spacing) - - self.table = CatalogueTable() - self.table.selectionModel().selectionChanged.connect( - self._handle_table_selection_changed - ) - layout.addWidget(self.table) - layout.addSpacing(dialog_spacing) - - project_name_layout = QtWidgets.QHBoxLayout() - project_name_layout.setAlignment(QtCore.Qt.AlignLeft) - project_name_layout.addWidget(widgets.ABLabel.demiBold("Project name:")) - self.project_name = QtWidgets.QLineEdit() - self.project_name.setText("") - self.project_name.textChanged.connect(self._handle_project_name_changed) - project_name_layout.addWidget(self.project_name) - layout.addLayout(project_name_layout) - - self._overwrite_checkbox = QtWidgets.QCheckBox("Overwrite existing project") - self._overwrite_checkbox.clicked.connect(self._handle_overwrite_clicked) - layout.addWidget(self._overwrite_checkbox) - - self._activate_project_checkbox = QtWidgets.QCheckBox("Activate project after import") - self._activate_project_checkbox.setChecked(True) - layout.addWidget(self._activate_project_checkbox) - - import_button_layout = QtWidgets.QHBoxLayout() - self.import_button = QtWidgets.QPushButton("Create project") - import_button_layout.addWidget(self.import_button) - self.import_button.clicked.connect(self._import_project) - layout.addLayout(import_button_layout) - self._message_label = QtWidgets.QLabel("") - layout.addWidget(self._message_label) - - self.setLayout(layout) - self._last_url = "" - # Initialize the dialog - self._populate_table() - - def _reset_dialog(self): - self.table.setEnabled(False) - self.table.populate(dict()) - self.table.selectionModel().clearSelection() - self.project_name.setEnabled(False) - self.project_name.setPlaceholderText("") - self._overwrite_checkbox.setEnabled(False) - self._overwrite_checkbox.setChecked(False) - self._activate_project_checkbox.setEnabled(False) - self.import_button.setEnabled(False) - self._message_label.setText("") - - def url(self) -> str: - return urljoin(self.remote_url_path.text(), self.remote_catalogue.text()) - - def _populate_table(self): - self._reset_dialog() - try: - self.refresh_button.setText("Downloading...") - self.refresh_button.setEnabled(False) - self.repaint() - self.setCursor(QtCore.Qt.WaitCursor) - self._last_url = self.url() - data = requests.get(self._last_url).json() - self.table.setEnabled(True) - self.project_name.setEnabled(True) - self._activate_project_checkbox.setEnabled(True) - success = True - except: - data = {"Error loading catalogue": None} - self._message_label.setText("Load a valid catalogue") - success = False - - self.refresh_button.setText("Download catalogue") - self.refresh_button.setEnabled(True) - self.setCursor(QtCore.Qt.ArrowCursor) - self.table.populate(data) - if success: - self._check_project_already_exists() - - def _selected_project_name(self) -> str: - """Return the selected project name.""" - selection = self.table.selectedIndexes() - if selection: - selected_item: QtCore.QModelIndex = selection[0] - if selected_item.isValid(): - return selected_item.data() - return "" - - def _project_name(self) -> str: - """Return the user typed project name or, if empty, the selected one.""" - if self.project_name.text() == "": - return self._selected_project_name() - return self.project_name.text() - - def _handle_url_changed(self): - if self._last_url != self.url(): - self._reset_dialog() - self._message_label.setText("Load a valid catalogue") - - def _handle_table_selection_changed(self): - """ - Update the UI when the table selection changes. - - We set the currently selected project name as placeholder text, - to hint that it can be changed, or will be used as default. - """ - self.project_name.setPlaceholderText(self._selected_project_name()) - self._check_project_already_exists() - - def _handle_project_name_changed(self): - self._check_project_already_exists() - - def _unique_project_selection_update(self, selection_valid: bool): - """ - Update the UI when the selection in the table changes and the - project name is unique. - """ - if selection_valid: - self.import_button.setEnabled(True) - self._message_label.setText("") - else: - self.import_button.setEnabled(False) - self._message_label.setText("Select a project to import") - - def _duplicate_project_checkbox_update(self): - """ - Update the UI when the overwrite checkbox state changes and the - project name is not unique. - - Use the actual state of the checkbox, because it is not - called only from the checkbox click event. - """ - if self._overwrite_checkbox.isChecked(): - self.import_button.setEnabled(True) - self._message_label.setText("") - else: - self.import_button.setEnabled(False) - self._message_label.setText("Project name already exists") - - def _handle_overwrite_clicked(self): - self._duplicate_project_checkbox_update() - - def _check_project_already_exists(self): - """ - Update the overwrite checkbox and import button based on the project name. - - If the project already exists, it can only be imported with the - overwrite flag set. To make sure the user does not import it accidentaly, - the flag is reset every time the selected project or the project name changes. - """ - self._overwrite_checkbox.setChecked(False) - if self._project_name() in bd.projects: - self._overwrite_checkbox.setEnabled(True) - self._duplicate_project_checkbox_update() - else: - self._overwrite_checkbox.setEnabled(False) - # Disable the import if there is no selection - self._unique_project_selection_update(len(self.table.selectedIndexes()) > 0) - - def _import_project(self): - """ - Import the selected project with the new name. - It is checked with the UI flow, that there is a catalogue loaded, - a project to import selected and a unique name provided or the overwrite - flag is set. - """ - original_name = self._selected_project_name() - new_name = self._project_name() - if original_name and new_name: - logger.info(f"Importing project with name {new_name} " - f"(original name {original_name})") - self.import_button.setText("Creating project...") - self.import_button.setEnabled(False) - self.repaint() - self.setCursor(QtCore.Qt.WaitCursor) - - install_project( - original_name, - new_name, - url=self.remote_url_path.text(), - overwrite_existing=self._overwrite_checkbox.isChecked() - ) - if self._activate_project_checkbox.isChecked(): - bd.projects.set_current(new_name, update=False) - self.setCursor(QtCore.Qt.ArrowCursor) - self.accept() - else: - logger.error(f"Project name ({new_name}) or import name ({original_name}) is not valid.") - - - -class ProjectRemoteImport(ABAction): - """ - ABAction to download a project file from a remote server. - Allows for customization of server URL, created project name, and whether or not to overwrite existing projects. - """ - - icon = icons.qicons.import_db - text = "Import remote project" - tool_tip = "Import a project file from a remote server" - - @staticmethod - @exception_dialogs - def run(): - window = ProjectRemoteImportWindow() - window.adjustSize() - window.exec_() diff --git a/activity_browser/app/actions/project/project_switch.py b/activity_browser/app/actions/project/project_switch.py deleted file mode 100644 index 9e3268b59..000000000 --- a/activity_browser/app/actions/project/project_switch.py +++ /dev/null @@ -1,118 +0,0 @@ -import datetime -from loguru import logger - -from qtpy import QtWidgets, QtCore - -import bw2data as bd - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui.core.application import global_shortcut - -from .project_migrate25 import ProjectMigrate25 - - - - -class ProjectSwitch(ABAction): - """ - Switch to a specified Brightway2 project. - - This method compares the given project name with the currently active project. - If the specified project is different, it switches to the new project, updates - the last opened timestamp, and logs the change. If the project is not Brightway25 - compatible, a warning is displayed. If the specified project is already active, - no action is taken. - - Args: - project_name (str): The name of the project to switch to. - - Logs: - Warning: If the project is not Brightway25 compatible. - Info: When the project is successfully switched. - Debug: If the specified project is already the current project. - """ - - text = "Switch project" - tool_tip = "Switch the project" - - @staticmethod - @exception_dialogs - def run(project_name: str, reload: bool = False): - # compare the new to the current project name and switch to the new one if the two are not the same - if project_name == bd.projects.current and not reload: - logger.debug(f"Brightway2 already selected: {project_name}") - return - - dialog = ProjectChangeDialog(project_name, reload, app.main_window) - dialog.show() - app.application.processEvents() - - # switch to the new project, don't auto update to brightway25 - bd.projects.set_current(project_name, update=False) - - if not bd.projects.twofive: - logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") - ProjectSwitch.set_warning_bar() - - logger.info(f"Brightway2 current project: {project_name}") - - # update the last opened timestamp - bd.projects.dataset.data["last_opened"] = datetime.datetime.now().isoformat() - bd.projects.dataset.save() - - app.application.processEvents() - dialog.close() - - @staticmethod - def set_warning_bar(): - app.main_window.addToolBar(ProjectWarningBar()) - - @global_shortcut("F5") - @staticmethod - def reload_project(): - ProjectSwitch.run(bd.projects.current, reload=True) - - -class ProjectChangeDialog(QtWidgets.QDialog): - def __init__(self, project_name: str, reload: bool, parent=None): - super().__init__(parent, QtCore.Qt.WindowTitleHint) - - title = "Reloading project" if reload else "Switching project" - subtitle = f"Reloading project: {project_name}" if reload else f"Switching to project: {project_name}" - - self.setWindowTitle(title) - self.setModal(True) - - self.label = QtWidgets.QLabel(subtitle, self) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(self.label) - self.setLayout(layout) - - -class ProjectWarningBar(QtWidgets.QToolBar): - def __init__(self, parent=None): - super().__init__(parent) - self.setMovable(False) - - warning_label = QtWidgets.QLabel(" This project is not Brightway25 compatible. ") - height = warning_label.minimumSizeHint().height() - - warning_icon = QtWidgets.QLabel(self) - qicon = app.application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) - pixmap = qicon.pixmap(height, height) - warning_icon.setPixmap(pixmap) - - migrate_label = QtWidgets.QLabel("
Migrate project now") - migrate_label.mouseReleaseEvent = lambda x: ProjectMigrate25.run(bd.projects.current) - - self.addWidget(warning_icon) - self.addWidget(warning_label) - self.addWidget(migrate_label) - - app.signals.project.changed.connect(self.deleteLater) - - def contextMenuEvent(self, event): - return None - diff --git a/activity_browser/app/actions/pyside_upgrade.py b/activity_browser/app/actions/pyside_upgrade.py deleted file mode 100644 index 1ed892b77..000000000 --- a/activity_browser/app/actions/pyside_upgrade.py +++ /dev/null @@ -1,102 +0,0 @@ -import qtpy -import os -import sys -import subprocess -import time - -from activity_browser import app -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.ui import icons - -from qtpy import QtWidgets -from qtpy.QtCore import Signal, SignalInstance - -from activity_browser.ui.core import threading - - -class PysideUpgrade(ABAction): - """ - ABAction to install PySide6 through PyPI/pip. Installs PySide6, sets the environment variable for QtPy to use - PySide6 and then restarts the Activity Browser through a subprocess. - """ - - icon = icons.qicons.forward - text = "Upgrade installation to PySide6" - - @classmethod - @exception_dialogs - def run(cls): - - # slot definition to update the progress dialog with thread updates - def update_dialog_slot(progress: int, label: str): - dialog.setValue(progress) - dialog.setLabelText(label) - - assert not qtpy.PYSIDE6, "Already running PySide6" - assert cls.in_conda(), "Not inside a Conda environment" - - # setup a progress dialog to show the user we're doing something - dialog = QtWidgets.QProgressDialog(app.main_window) - dialog.setWindowTitle("Upgrading GUI back-end") - dialog.setMaximum(0) - dialog.setCancelButton(None) - - # messages can get quite long, so enable word-wrapping - lbl = dialog.findChild(QtWidgets.QLabel) - lbl.setWordWrap(True) - - # initialize thread and connect signals - thread = PySideUpgradeThread(app.application) - thread.status.connect(update_dialog_slot) - thread.exit.connect(sys.exit) - - thread.start() - dialog.exec_() - - @staticmethod - def in_conda() -> bool: - """Returns true when the current shell is in a Conda environment.""" - return bool(os.environ.get("CONDA_DEFAULT_ENV", False)) - - -class PySideUpgradeThread(threading.ABThread): - exit: SignalInstance = Signal() - - def run_safely(self): - self.pip_installation() - self.restart() - - def pip_installation(self): - """ - Install PySide6 from PyPI using a subprocess.Popen call - """ - self.status.emit(0, "Installing PySide6 through pip") - - # open subprocess that installs PySide6 - process = subprocess.Popen(["pip", "install", "pyside6"], stdout=subprocess.PIPE) - - while process.poll() is None: # block until the subprocess is finished - # format stdout - line = process.stdout.readline().decode().strip() - if not line: - continue - - # redirect stdout to both console and progress dialog - print(line) - self.status.emit(0, line) - - assert process.returncode == 0, "Failed to install PySide6" - - def restart(self): - """ - Restarts the Activity Browser through a subprocess. Sleeps 5 seconds to allow the user to register - the restart. - """ - self.status.emit(0, "Restarting the Activity Browser") - subprocess.Popen(["python", "-c", "import activity_browser; activity_browser.run_activity_browser()"]) - time.sleep(5) - - # signal restart through the exit signal as sys.exit needs to be called in the main thread. - self.exit.emit() - - diff --git a/activity_browser/app/actions/save_parameters_to_excel.py b/activity_browser/app/actions/save_parameters_to_excel.py deleted file mode 100644 index 50543d9cb..000000000 --- a/activity_browser/app/actions/save_parameters_to_excel.py +++ /dev/null @@ -1,39 +0,0 @@ -import os - -import pandas as pd - -from qtpy import QtWidgets - -from activity_browser.app import application -from activity_browser.app.actions.base import ABAction, exception_dialogs -from activity_browser.bwutils.utils import Parameters - - -class SaveParametersToExcel(ABAction): - """ - ABAction to export database(s) to Excel format (.xlsx). - """ - text = "Save parameters to Excel (.xlsx)" - tool_tip = "Save parameters to Excel format" - - @classmethod - @exception_dialogs - def run(cls, file_path: str = None): - if file_path is None: - suggestion = os.path.expanduser("~/parameters.xlsx") - - file_path, _ = QtWidgets.QFileDialog.getSaveFileName( - parent=application.main_window, - caption=f'Export parameters to Excel', - dir=suggestion, - filter='Excel spreadsheet (*.xlsx);; All files (*.*)' - ) - - if not file_path: - return - - data = [p[:3] for p in Parameters.from_bw_parameters()] - df = pd.DataFrame(data, columns=["Name", "Group", "default"]).set_index("Name") - df.to_excel(file_path) - - os.startfile(file_path) diff --git a/activity_browser/app/actions/tools/bw2io/tools_bw2io_migrations.py b/activity_browser/app/actions/tools/bw2io/tools_bw2io_migrations.py deleted file mode 100644 index 70e214027..000000000 --- a/activity_browser/app/actions/tools/bw2io/tools_bw2io_migrations.py +++ /dev/null @@ -1,19 +0,0 @@ -from activity_browser.app.actions.base import ABAction, exception_dialogs - -from activity_browser.ui.icons import qicons - -from activity_browser.mod.bw2io.migrations import ab_create_core_migrations - - -class ToolsBW2IOCreateMigrations(ABAction): - """ - ABAction to install default migrations from bw2io - """ - - icon = qicons.import_db - text = "Install default bw2io migrations" - - @staticmethod - @exception_dialogs - def run(): - ab_create_core_migrations() diff --git a/activity_browser/app/dialogs/README.md b/activity_browser/app/dialogs/README.md deleted file mode 100644 index 95087a8d4..000000000 --- a/activity_browser/app/dialogs/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# dialogs - -Dialog windows for user interactions throughout Activity Browser. - -## Overview - -This directory contains modal and non-modal dialog windows used for various user interactions such as data entry, configuration, selection, and information display. Dialogs in the app directory are there because they are tightly integrated with Brightway2 or depend on the application for other reasons. - -- Generally, action specific dialogs are located alongside the corresponding action in the `actions/` directory. -- Dialogs than can be applied more widely and are not intimately tied with either actions or Brightway2 are located in the `ui/dialogs/` directory. -- Only if the above two locations are not appropriate should a dialog be placed here. - -What qualifies to be put in this directory is somewhat subjective, but the guiding principle is that these dialogs are core to the functioning of Activity Browser and are not easily reusable outside of it. \ No newline at end of file diff --git a/activity_browser/app/dialogs/__init__.py b/activity_browser/app/dialogs/__init__.py deleted file mode 100644 index 5a337f238..000000000 --- a/activity_browser/app/dialogs/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .import_preview_dialog import ImportPreviewDialog -from .node_select_dialog import NodeSelectDialog -from .database_select_dialog import DatabaseSelectDialog diff --git a/activity_browser/app/dialogs/database_select_dialog.py b/activity_browser/app/dialogs/database_select_dialog.py deleted file mode 100644 index 8639ef2c2..000000000 --- a/activity_browser/app/dialogs/database_select_dialog.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import List - -from qtpy import QtWidgets - - -class DatabaseSelectDialog(QtWidgets.QDialog): - """Dialog to select one or more databases for export.""" - - def __init__(self, parent=None, databases=None, title="Select databases"): - super().__init__(parent=parent) - self.setWindowTitle(title) - self.setModal(True) - self.resize(400, 300) - - layout = QtWidgets.QVBoxLayout(self) - - self.db_list_widget = QtWidgets.QListWidget(self) - self.db_list_widget.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) - for db_name in databases: - item = QtWidgets.QListWidgetItem(db_name) - self.db_list_widget.addItem(item) - layout.addWidget(self.db_list_widget) - - button_box = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - parent=self - ) - button_box.accepted.connect(self.accept) - button_box.rejected.connect(self.reject) - layout.addWidget(button_box) - - def get_selected_databases(self) -> List[str]: - """Return the list of selected database names.""" - selected_items = self.db_list_widget.selectedItems() - return [item.text() for item in selected_items] diff --git a/activity_browser/app/dialogs/import_preview_dialog/__init__.py b/activity_browser/app/dialogs/import_preview_dialog/__init__.py deleted file mode 100644 index 885add2a0..000000000 --- a/activity_browser/app/dialogs/import_preview_dialog/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .import_preview_dialog import ImportPreviewDialog \ No newline at end of file diff --git a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py deleted file mode 100644 index 203863cfa..000000000 --- a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py +++ /dev/null @@ -1,253 +0,0 @@ -from PySide6.QtCore import QModelIndex -from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt - -from loguru import logger - -import pandas as pd - -from bw2io.importers.base_lci import LCIImporter - -from activity_browser.ui import widgets, core, delegates, icons - -from ..node_select_dialog import NodeSelectDialog - - -class ImportPreviewEdgeTab(QtWidgets.QWidget): - standardEdgeColumns = ["linked", "type", "amount", "unit", "input", "name", "location", "database", "formula"] - - def __init__(self, importer: LCIImporter, parent=None): - super().__init__(parent) - self.importer = importer - self.simple = True - self.old_links: dict[tuple[int, int], tuple[str, str] | None] = {} - - layout = QtWidgets.QVBoxLayout(self) - - self.edge_model = ImportPreviewEdgeModel(parent=self) - self.edge_model.set_dataframe(self.build_df()) - self.edge_model.group(["_node"]) - - self.edge_view = ImportPreviewEdgeView(importer, self) - self.edge_view.setUniformRowHeights(False) - self.edge_view.setModel(self.edge_model) - self.edge_view.setColumnWidth(0, 0) - - # Create simple/detailed view toggle - self.view_toggle = QtWidgets.QCheckBox("Details") - self.view_toggle.setChecked(not self.simple) - self.view_toggle.setToolTip("Toggle between simple and detailed view") - self.view_toggle.checkStateChanged.connect(self.on_mode_switch) - - # Create top bar with toggle - top_bar = QtWidgets.QHBoxLayout() - top_bar.addStretch() - top_bar.addWidget(self.view_toggle) - - layout.addLayout(top_bar) - layout.addWidget(self.edge_view) - - self.sync() - - def sync(self): - """Synchronize the view based on simple/detailed mode.""" - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - self.edge_view.header().setHidden(self.simple) - self.edge_view.viewport().setBackgroundRole( - QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) - self.edge_view.setFrameShape( - QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) - - df = self.build_df() - - if self.simple and "_exc" in df.columns: - df.rename(columns={"_exc": "exc"}, inplace=True) - elif not self.simple and "node" in df.columns: - df.rename(columns={"exc": "_exc"}, inplace=True) - - self.edge_model.update_dataframe(df) - - for col in self.edge_model.columns(): - if col == "index": - continue - index = self.edge_model.columns().index(col) - - hidden = (self.simple and not col == "exc") or (not self.simple and col == "exc") - self.edge_view.setColumnHidden(index, hidden) - - def build_df(self): - - exchanges = [] - for node_i, node in enumerate(self.importer.data): - summary = [ - node.get("name"), - node.get("location"), - node.get("database"), - node.get("code"), - ] - summary = " | ".join([str(part) for part in summary if part]) - - for exc_i, exc in enumerate(node.get("exchanges", [])): - exc = exc.copy() - exc["_node"] = summary - exc["_location"] = (node_i, exc_i) - exchanges.append(exc) - - df = pd.DataFrame(exchanges) - for col in [col for col in self.standardEdgeColumns if col not in df.columns]: - df[col] = None - df["exc"] = None - - def determine_link_status(row): - input_val = row["input"] - location = row["_location"] - - if not isinstance(input_val, tuple): - return "unlinked" - elif location in self.old_links: - return "relinked" - else: - return "linked" - - df["linked"] = df.apply(determine_link_status, axis=1) - - return df - - def on_mode_switch(self, check: Qt.CheckState): - """Handle the mode switch between simple and detailed view.""" - self.simple = check == Qt.CheckState.Unchecked - self.sync() - - def relink_selected_exchanges(self): - """Open a dialog to link selected exchanges to existing nodes.""" - exchange_locations = self.edge_view.selected_exchanges - if not exchange_locations: - return - - dialog = NodeSelectDialog(parent=self) - if not dialog.exec() == QtWidgets.QDialog.DialogCode.Accepted: - return - - selected_node = dialog.get_selected_node() - - for loc in exchange_locations: - node_i, exc_i = loc - - if loc not in self.old_links: - self.old_links[loc] = self.importer.data[node_i]["exchanges"][exc_i].get("input") - - self.importer.data[node_i]["exchanges"][exc_i]["input"] = (selected_node["database"], selected_node["code"]) - - self.sync() - - -class ShiftedCardDelegate(delegates.CardDelegate): - """ - Delegate that shifts the card content to the left to compensate for indentation. - """ - def paint(self, painter, option, index): - # Adjust the rect to shift content left, compensating for indentation - adjusted_option = QtWidgets.QStyleOptionViewItem(option) - adjusted_option.rect.adjust(-28, 0, 0, 0) - - # Call the original paint with adjusted rect - super().paint(painter, adjusted_option, index) - - -class ImportPreviewEdgeView(widgets.ABTreeView): - """View for displaying import preview nodes.""" - - defaultColumnDelegates = { - "exc": ShiftedCardDelegate, - } - - class ContextMenu(widgets.ABMenu): - menuSetup = [ - lambda m, p: m.callback( - text="Link exchange" if len(p.selected_exchanges) == 1 else "Link exchanges", - func=p.tab.relink_selected_exchanges, - ) - ] - - def __init__(self, importer: LCIImporter, tab: ImportPreviewEdgeTab): - super().__init__(tab) - self.importer = importer - self.old_links = {} - self.tab = tab - - @property - def selected_exchanges(self): - """ - Returns a list of selected exchange locations as (node_index, exchange_index) tuples. These can be used to - identify and manipulate the selected exchanges in the importer's data, which is a list of lists. - """ - return list(set([self.model().get(index, "_location") for index in self.selectedIndexes()])) - - -class ImportPreviewEdgeModel(core.ABTreeModel): - """Model for import preview nodes with node delegate support.""" - - def displayData(self, index: QtCore.QModelIndex) -> any: - if not index.isValid(): - return None - - column_name = self.columns()[index.column()] - if not column_name == "exc" or self.row(index) is None: - return super().displayData(index) - - row_data = self.row(index).copy() - row_data.dropna(inplace=True) - - # Build the card information - title = row_data.get('reference product') or row_data.get('name') - subtitle = row_data.get('name') - detail = f"{row_data.get('amount')} {row_data.get('unit')}" - - # Build categories list from unit, location - categories = [] - if row_data.get("type"): - categories.append(str(row_data.get("type"))) - if row_data.get("location"): - categories.append(str(row_data.get("location"))) - if row_data.get("categories"): - categories.append(", ".join([str(cat) for cat in row_data.get("categories")])) - if row_data.get("database"): - categories.append(str(row_data.get("database"))) - - return { - "title": title, - "subtitle": subtitle, - "categories": categories if categories else None, - "detail": detail, - } - - - def decorationData(self, index: QModelIndex) -> QtGui.QIcon: - if not index.isValid(): - return icons.qicons.empty - - column_name = self.columns()[index.column()] - if not column_name in ["exc"]: - return super().decorationData(index) - - linked = self.get(index, "linked") - if linked == "linked": - return icons.qicons.link - elif linked == "unlinked": - return icons.qicons.unlink - elif linked == "relinked": - return icons.qicons.relink - return icons.qicons.empty - - def indexSelectable(self, index: QModelIndex) -> bool: - # Don't make the tree column selectable - if index.column() == 0: - return False - return True - - - - - - diff --git a/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py b/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py deleted file mode 100644 index 4c4eb3c0d..000000000 --- a/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py +++ /dev/null @@ -1,30 +0,0 @@ -from qtpy import QtWidgets, QtCore, QtGui - -import pandas as pd - -from bw2io.importers.base_lci import LCIImporter - -from activity_browser.ui import widgets, core - -from .node_tab import ImportPreviewNodeTab -from .edge_tab import ImportPreviewEdgeTab - - -class ImportPreviewDialog(QtWidgets.QDialog): - def __init__(self, importer: LCIImporter, parent=None): - super().__init__(parent) - self.setWindowTitle("Import Preview") - self.resize(600, 400) - - self.importer = importer - self.tabs = QtWidgets.QTabWidget(self) - - self.node_tab = ImportPreviewNodeTab(importer, self) - self.edge_tab = ImportPreviewEdgeTab(importer, self) - - self.tabs.addTab(self.node_tab, "Nodes") - self.tabs.addTab(self.edge_tab, "Edges") - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(self.tabs) - self.setLayout(layout) diff --git a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py deleted file mode 100644 index de6e72f02..000000000 --- a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py +++ /dev/null @@ -1,172 +0,0 @@ -from PySide6.QtCore import QModelIndex -from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt - -from loguru import logger - -import pandas as pd - -from bw2io.importers.base_lci import LCIImporter - -from activity_browser.ui import widgets, core, delegates, icons - - -class ImportPreviewNodeTab(QtWidgets.QWidget): - standardNodeColumns = ["type", "name", "product", "exchanges", "unlinked_exchanges", "location", "unit", "categories", "code", - "database"] - standardEdgeColumns = ["type", "amount", "unit", "input", "name", "location", "database", "formula"] - - def __init__(self, importer: LCIImporter, parent=None): - super().__init__(parent) - self.importer = importer - self.simple = True - - layout = QtWidgets.QVBoxLayout(self) - - self.node_model = ImportPreviewNodeModel(parent=self) - self.node_model.set_dataframe(self.build_df()) - - self.node_view = ImportPreviewNodeView(parent=self) - self.node_view.setModel(self.node_model) - - # Create simple/detailed view toggle - self.view_toggle = QtWidgets.QCheckBox("Details") - self.view_toggle.setChecked(not self.simple) - self.view_toggle.setToolTip("Toggle between simple and detailed view") - self.view_toggle.checkStateChanged.connect(self.on_mode_switch) - - # Create top bar with toggle - top_bar = QtWidgets.QHBoxLayout() - top_bar.addStretch() - top_bar.addWidget(self.view_toggle) - - layout.addLayout(top_bar) - layout.addWidget(self.node_view) - - self.sync() - - def sync(self): - """Synchronize the view based on simple/detailed mode.""" - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - self.node_view.header().setHidden(self.simple) - self.node_view.viewport().setBackgroundRole( - QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) - self.node_view.setFrameShape( - QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) - - df = self.node_model.df.copy() - if self.simple and "_node" in df.columns: - df.rename(columns={"_node": "node"}, inplace=True) - elif not self.simple and "node" in df.columns: - df.rename(columns={"node": "_node"}, inplace=True) - self.node_model.set_dataframe(df) - - for col in self.node_model.columns(): - if col == "index": - continue - index = self.node_model.columns().index(col) - - hidden = (self.simple and not col == "node") or (not self.simple and col == "node") - self.node_view.setColumnHidden(index, hidden) - - def build_df(self): - node_df = pd.DataFrame(self.importer.data) - for col in [col for col in self.standardNodeColumns if col not in node_df.columns]: - node_df[col] = None - - node_df["_exchanges"] = node_df["exchanges"] - node_df["unlinked_exchanges"] = node_df["exchanges"].apply( - lambda x: sum(1 for ex in x if not ex.get("input")) if isinstance(x, list) else 0 - ) - node_df["exchanges"] = node_df["exchanges"].apply(lambda x: len(x) if isinstance(x, list) else 0) - - node_df = node_df[ - self.standardNodeColumns + - [col for col in node_df.columns if col not in self.standardNodeColumns] - ] - node_df["_importer_index"] = range(len(node_df)) - - node_df["node"] = None - - return node_df - - def on_mode_switch(self, check: Qt.CheckState): - """Handle the mode switch between simple and detailed view.""" - self.simple = check == Qt.CheckState.Unchecked - self.sync() - - -class ImportPreviewNodeView(widgets.ABTreeView): - """View for displaying import preview nodes.""" - - defaultColumnDelegates = { - "node": delegates.CardDelegate, - } - - -class ImportPreviewNodeModel(core.ABTreeModel): - """Model for import preview nodes with node delegate support.""" - - def displayData(self, index: QtCore.QModelIndex) -> any: - if not index.isValid(): - return None - - column_name = self.columns()[index.column()] - if not column_name == "node": - return super().displayData(index) - - row_data = self.row(index).copy() - row_data.dropna(inplace=True) - - # Get the product or name for title - title = row_data.get("product") or row_data.get("name") - - # Build subtitle with type and database - if row_data.get("categories"): - subtitle = ", ".join([str(cat) for cat in row_data.get("categories")]) - elif row_data.get("product"): - subtitle = row_data.get("name") - else: - excs = row_data.get("exchanges") - unlinked = row_data.get("unlinked_exchanges") - nomination = "exchanges" if excs != 1 else "exchange" - - subtitle = f"{excs} {nomination}, {unlinked} unlinked" - - # Build categories list from unit, location - categories = [] - if row_data.get("unit"): - categories.append(str(row_data.get("unit"))) - if row_data.get("location"): - categories.append(str(row_data.get("location"))) - if row_data.get("database"): - categories.append(str(row_data.get("database"))) - - return { - "title": title, - "subtitle": subtitle, - "categories": categories if categories else None, - } - - - def decorationData(self, index: QModelIndex) -> QtGui.QIcon: - if not index.isValid(): - return icons.qicons.empty - - column_name = self.columns()[index.column()] - if not column_name in ["node", "type"]: - return super().decorationData(index) - - node_type = self.get(index, "type") - - if node_type == "product": - return icons.qicons.product - if node_type == "waste": - return icons.qicons.waste - if node_type == "processwithreferenceproduct": - return icons.qicons.processproduct - if node_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: - return icons.qicons.biosphere - return icons.qicons.process - diff --git a/activity_browser/app/dialogs/node_select_dialog.py b/activity_browser/app/dialogs/node_select_dialog.py deleted file mode 100644 index ffc805af5..000000000 --- a/activity_browser/app/dialogs/node_select_dialog.py +++ /dev/null @@ -1,196 +0,0 @@ -from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt -import pandas as pd - -from activity_browser.ui import widgets, core, delegates, icons -from activity_browser.app import metadata -from activity_browser.bwutils.commontasks import refresh_node - - -class NodeSelectDialog(QtWidgets.QDialog): - node_selected = QtCore.Signal(dict) - - def __init__(self, parent=None, drag_enabled=False): - super().__init__(parent) - - self.setWindowFlags( - QtCore.Qt.WindowType.Popup | - QtCore.Qt.WindowType.FramelessWindowHint - ) - self.setFixedWidth(400) - self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.Maximum) - - self.edit = widgets.ABLineEdit(self) - self.edit.setPlaceholderText("Enter text to search for a node") - self.edit.textChangedDebounce.connect(self.on_search) - - # Create model and tree view for results - self.model = NodeSearchModel(parent=self) - self.tree_view = NodeSearchView(self) - self.tree_view.setModel(self.model) - - self.tree_view.clicked.connect(self.accept) - self.tree_view.dragStarted.connect(self.on_drag_started) - self.tree_view.setDragEnabled(drag_enabled) - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(5, 5, 5, 0) - layout.addWidget(self.edit) - layout.addWidget(self.tree_view) - self.setLayout(layout) - - self.setFixedHeight(self.sizeHint().height()) - - def showEvent(self, event): - super().showEvent(event) - self.edit.setFocus() - - def on_search(self, text: str): - if not text.strip(): - # Clear results - self.model.set_dataframe(pd.DataFrame()) - self.tree_view.setFixedHeight(0) - self.adjustSize() - self.setFixedHeight(self.sizeHint().height()) - return - - # Search and get results - result_df = metadata.search(text) - result_df = result_df[0:10] if len(result_df) > 10 else result_df - - # Add a placeholder "node" column for the CardDelegate - result_df["node"] = None - - # Update model with search results - self.model.set_dataframe(result_df) - - # Adjust height based on results - if len(result_df) > 0: - self.tree_view.setFixedHeight(min(400, len(result_df) * 80 + 20)) - else: - self.tree_view.setFixedHeight(0) - - # Adjust dialog to minimum size - self.adjustSize() - self.setFixedHeight(self.sizeHint().height()) - - def on_drag_started(self): - """Handle when a drag operation is started""" - self.hide() # Close the dialog - - def get_selected_node(self): - """Return the currently selected node data""" - index = self.tree_view.currentIndex() - if not index.isValid(): - return None - node_id = self.model.get(index, "id") - if not node_id: - return None - return refresh_node(node_id) - - -class NodeSearchModel(core.ABTreeModel): - """Model for displaying search results in the node select dialog.""" - - def columns(self) -> list[str]: - return ["index", "node"] - - def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: - return True - - def displayData(self, index: QtCore.QModelIndex) -> any: - if not index.isValid(): - return None - - column_name = self.columns()[index.column()] - if not column_name == "node": - return super().displayData(index) - - row_data = self.row(index).copy() - row_data.dropna(inplace=True) - - # Get the product or name for title - title = row_data.get("product") or row_data.get("name") - - # Build subtitle with type and database - if row_data.get("categories"): - subtitle = ", ".join([str(cat) for cat in row_data.get("categories")]) - elif row_data.get("product"): - subtitle = row_data.get("name") - else: - subtitle = "" - - # Build categories list from unit, location - categories = [] - if row_data.get("unit"): - categories.append(str(row_data.get("unit"))) - if row_data.get("location"): - categories.append(str(row_data.get("location"))) - if row_data.get("database"): - categories.append(str(row_data.get("database"))) - - return { - "title": title, - "subtitle": subtitle, - "categories": categories if categories else None, - } - - def decorationData(self, index: QtCore.QModelIndex) -> QtGui.QIcon: - if not index.isValid(): - return icons.qicons.empty - - node_type = self.get(index, "type") - - if node_type == "product": - return icons.qicons.product - if node_type == "waste": - return icons.qicons.waste - if node_type == "processwithreferenceproduct": - return icons.qicons.processproduct - if node_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: - return icons.qicons.biosphere - return icons.qicons.process - - def mimeData(self, indices: list[QtCore.QModelIndex]): - """ - Returns the mime data for the given indices. - - Args: - indices (list[QtCore.QModelIndex]): The indices to get the mime data for. - - Returns: - core.ABMimeData: The mime data. - """ - data = core.ABMimeData() - keys = [self.row(index).get("key") for index in indices if index.isValid()] - keys = {key for key in keys if isinstance(key, tuple)} - data.setPickleData("application/bw-nodekeylist", list(keys)) - return data - - -class NodeSearchView(widgets.ABTreeView): - """Tree view for displaying node search results.""" - dragStarted: QtCore.SignalInstance = QtCore.Signal() - - defaultColumnDelegates = { - "node": delegates.CardDelegate, - } - - def __init__(self, parent: NodeSelectDialog): - super().__init__(parent) - self.setSelectionBehavior(widgets.ABTreeView.SelectionBehavior.SelectRows) - self.setSelectionMode(widgets.ABTreeView.SelectionMode.SingleSelection) - self.viewport().setBackgroundRole(QtGui.QPalette.ColorRole.Window) - self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) - - self.setHeaderHidden(True) - - self.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) - self.setFixedHeight(0) - - - def startDrag(self, supportedActions: Qt.DropAction) -> None: - self.dragStarted.emit() - super().startDrag(supportedActions) - diff --git a/activity_browser/app/main.py b/activity_browser/app/main.py deleted file mode 100644 index 3c0e99629..000000000 --- a/activity_browser/app/main.py +++ /dev/null @@ -1,282 +0,0 @@ -from pathlib import Path -from loguru import logger - -from qtpy import QtCore, QtWidgets - -import bw2data as bd -from activity_browser import app -from activity_browser.ui import widgets - - -class MainWindow(QtWidgets.QMainWindow): - _instance = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance._initialized = False - return cls._instance - - - def __init__(self, parent=None): - from activity_browser.app.menu_bar import MenuBar - - if self._initialized: - return - self._initialized = True - - super().__init__(parent) - - self.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) - self.setWindowTitle("Activity Browser") - self.setDockNestingEnabled(True) - - # Layout: extra items outside main layout - self.menu_bar = MenuBar(self) - self.setMenuBar(self.menu_bar) - - self.central_widget = widgets.CentralTabWidget(self) - self.central_widget.setTabsClosable(True) - self.setCentralWidget(self.central_widget) - - # Initialize all base pages upfront (name -> widget instance) - self.base_pages = {} - for page_name, page_class in app.pages.base_pages.items(): - page_instance = page_class() - page_instance.setObjectName(page_name) - self.base_pages[page_name] = page_instance - - # Connect tab close signal - self.central_widget.tabCloseRequested.connect(self._on_tab_close_requested) - - self.connect_signals() - self.destroyed.connect(lambda: logger.warning("MainWindow destroyed")) - - def event(self, event): - if event.type() == QtCore.QEvent.Type.DeferredDelete: - for page in self.base_pages.values(): - logger.debug(f"Destroying base page {page.__class__.__name__}: {id(page)}") - try: - page.deleteLater() - except RuntimeError: - # page already deleted - pass - return super().event(event) - - def sync(self): - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - self.sync_panes() - self.sync_pages() - - self.setWindowTitle(f"Activity Browser - {bd.projects.current}") - - def sync_panes(self): - self.clearPanes() - - dws = [] - - # Iterate through the default panes and add them as dock widgets - for pane_name, pane_class in app.panes.base_panes.items(): - pane = pane_class(parent=self) - dockwidget = pane.getDockWidget(self) - dws.append(dockwidget) - - # Add the dock widget to the left dock area - self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dockwidget) - # Add the toggle view action to the menu bar - self.menu_bar.view_menu.addAction(dockwidget.toggleViewAction()) - - # Hide the dock widget if it is marked as hidden - if pane_name not in app.settings["startup"]["shown_panes"]: - dockwidget.hide() - - # Synchronize the pane - pane.sync() - - # Tabify the dock widgets for better organization - for dw in dws: - if dw == dws[0]: - continue - self.tabifyDockWidget(dws[0], dw) - - # Raise the first dock widget to the top - dws[0].raise_() - - def sync_pages(self): - """ - Synchronizes the central widget pages with the shown_pages setting. - - This method shows only those pages that are configured to be shown at startup. - Pages are pre-initialized and just added/removed from tabs. - """ - # Get shown pages from settings - shown_pages = app.settings["startup"].get("shown_pages", []) - - # Remove all pages from tabs first - while self.central_widget.count() > 0: - self.central_widget.removeTab(0) - - # Add only the pages that should be shown - for page_name in shown_pages: - if page_name in self.base_pages: - page_instance = self.base_pages[page_name] - # Base pages should show minimize button instead of close - self.central_widget.addTab(page_instance, page_name, show_minimize=True) - - def show_page(self, page_name: str): - """ - Show a page by adding it to the tabs. - - Args: - page_name: The name of the page to show - """ - if page_name not in self.base_pages: - return - - page_widget = self.base_pages[page_name] - - # Check if page is already in tabs - index = self.central_widget.indexOf(page_widget) - if index >= 0: - # Already shown, just switch to it - self.central_widget.setCurrentIndex(index) - else: - # Add to tabs with minimize button - self.central_widget.addTab(page_widget, page_name, show_minimize=True) - self.central_widget.setCurrentWidget(page_widget) - - def hide_page(self, page_name: str): - """ - Hide a page by removing it from the tabs (but not destroying it). - - Args: - page_name: The name of the page to hide - """ - if page_name not in self.base_pages: - return - - page_widget = self.base_pages[page_name] - index = self.central_widget.indexOf(page_widget) - if index >= 0: - self.central_widget.removeTab(index) - - def toggle_page(self, page_name: str): - """ - Toggle a page shown/hidden. - - Args: - page_name: The name of the page to toggle - """ - if page_name not in self.base_pages: - return - - page_widget = self.base_pages[page_name] - index = self.central_widget.indexOf(page_widget) - - if index >= 0: - # Page is shown, hide it - self.hide_page(page_name) - else: - # Page is hidden, show it - self.show_page(page_name) - - def is_page_visible(self, page_name: str) -> bool: - """ - Check if a page is currently visible in the tabs. - - Args: - page_name: The name of the page to check - - Returns: - bool: True if the page is visible, False otherwise - """ - if page_name not in self.base_pages: - return False - - page_widget = self.base_pages[page_name] - return self.central_widget.indexOf(page_widget) >= 0 - - def _on_tab_close_requested(self, index: int): - """ - Handle when user clicks the close button on a tab. - For base pages, we just hide them instead of destroying them. - - Args: - index: The index of the tab to close - """ - widget = self.central_widget.widget(index) - if widget is None: - return - - # Check if this is a base page - page_name = widget.objectName() - if page_name in self.base_pages: - # Just remove from tabs, don't destroy - self.central_widget.removeTab(index) - else: - # For non-base pages, remove and destroy - self.central_widget.removeTab(index) - widget.deleteLater() - - def apply_settings(self, load=False): - - base_dir = Path(app.settings["startup"]["brightway_directory"]) - - if load or base_dir != bd.projects._base_data_dir: - project_name = app.settings["startup"]["startup_project"] - bd.projects.change_base_directories(base_dir, project_name=project_name, update=False) - - if not bd.projects.twofive: - logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") - app.actions.ProjectSwitch.set_warning_bar() - - # Apply color scheme settings - if app.settings["appearance"]["theme"] == "dark": - hint = QtCore.Qt.ColorScheme.Dark - elif app.settings["appearance"]["theme"] == "light": - hint = QtCore.Qt.ColorScheme.Light - else: - hint = QtCore.Qt.ColorScheme.Unknown - - app.application.styleHints().setColorScheme(hint) - - # apply pane tab position - position = app.settings["appearance"]["pane_tab_position"] - if position == "top": - qt_position = QtWidgets.QTabWidget.North - if position == "bottom": - qt_position = QtWidgets.QTabWidget.South - if position == "left": - qt_position = QtWidgets.QTabWidget.West - if position == "right": - qt_position = QtWidgets.QTabWidget.East - self.setTabPosition(QtCore.Qt.DockWidgetArea.AllDockWidgetAreas, qt_position) - - def connect_signals(self): - app.signals.project.changed.connect(self.sync) - app.signals.settings.changed.connect(self.apply_settings) - - def clearPanes(self): - for pane in self.panes(): - logger.debug(f"Clearing pane {pane.__class__.__name__}: {id(pane)}") - pane.deleteLater() - - def panes(self): - """ - Return a list of all panes in the main window. - """ - from activity_browser.ui import widgets - QtWidgets.QApplication.processEvents() - return self.findChildren(widgets.ABAbstractPane) - - def set_titlebar(self): - self.setWindowTitle(f"Activity Browser - {bd.projects.current}") - - def dialog_on_exception(self, exception: Exception): - QtWidgets.QMessageBox.critical( - self, - f"An error occurred: {type(exception).__name__}", - f"An error occurred, check the logs for more information \n\n {str(exception)}", - QtWidgets.QMessageBox.Ok, - ) - diff --git a/activity_browser/app/menu_bar.py b/activity_browser/app/menu_bar.py deleted file mode 100644 index 46f25507f..000000000 --- a/activity_browser/app/menu_bar.py +++ /dev/null @@ -1,336 +0,0 @@ -from importlib.metadata import version -from loguru import logger - -import bw2data as bd - -from qtpy import QtGui, QtWidgets, QtCore -from qtpy.QtCore import QSize, QUrl, Qt - -from activity_browser import app -from activity_browser.bwutils.commontasks import get_templates - -from ..ui.icons import qicons - - -class MenuBar(QtWidgets.QMenuBar): - """ - Main menu bar at the top of the Activity Browser window. Contains submenus for different user interaction categories - """ - def __init__(self, window): - super().__init__(parent=window) - - self.project_menu = ProjectMenu(self) - self.view_menu = ViewMenu(self) - self.calculate_menu = CalculateMenu(self) - self.help_menu = HelpMenu(self) - - self.addMenu(self.project_menu) - self.addMenu(self.view_menu) - self.addMenu(self.calculate_menu) - self.addMenu(self.help_menu) - - self.search_button = QtWidgets.QPushButton(self) - self.search_button.setFlat(True) - self.search_button.setIcon(qicons.search) - self.search_button.setIconSize(QtCore.QSize(13, 13)) - self.search_button.setToolTip("Search project (Ctrl+Shift+F)") - self.search_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.search_button.clicked.connect(app.actions.NodeSelectOpen.run) - self.setCornerWidget(self.search_button, Qt.Corner.TopRightCorner) - - -class ProjectMenu(QtWidgets.QMenu): - """ - Project menu: contains actions related to managing the project, such as project duplication, database importing etc. - """ - - def __init__(self, parent=None) -> None: - super().__init__(parent) - - self.setTitle("&Project") - - self.dup_proj_action = app.actions.ProjectDuplicate.get_QAction() - self.delete_proj_action = app.actions.ProjectDelete.get_QAction() - - self.import_proj_action = app.actions.ProjectImport.get_QAction() - self.export_proj_action = app.actions.ProjectExport.get_QAction() - - self.addMenu(ProjectSelectionMenu(self)) - self.addMenu(ProjectNewMenu(self)) - self.addAction(self.dup_proj_action) - self.addAction(self.delete_proj_action) - self.addSeparator() - self.addAction(self.import_proj_action) - self.addAction(self.export_proj_action) - self.addSeparator() - self.addMenu(ImportDatabaseMenu(self)) - self.addMenu(ExportDatabaseMenu(self)) - self.addSeparator() - self.addMenu(ImportICMenu(self)) - - -class ProjectNewMenu(QtWidgets.QMenu): - def __init__(self, parent=None) -> None: - super().__init__(parent) - - self.setTitle("New project") - self.new_proj_action = app.actions.ProjectNew.get_QAction() - self.import_proj_action = app.actions.ProjectImport.get_QAction() - - self.new_proj_action.setText("Empty project") - self.import_proj_action.setText("From .tar.gz file") - - self.new_proj_action.setIcon(QtGui.QIcon()) - self.import_proj_action.setIcon(QtGui.QIcon()) - - self.addAction(self.new_proj_action) - self.addAction(self.import_proj_action) - self.addMenu(ProjectNewTemplateMenu(self)) - - -class ProjectNewTemplateMenu(QtWidgets.QMenu): - remote_projects = {} - - def __init__(self, parent=None): - super().__init__(parent) - self.setTitle("From template") - - self.actions = {} - - for key in get_templates(): - action = app.actions.ProjectNewFromTemplate.get_QAction(key) - action.setText(key) - self.actions[key] = action - self.addAction(action) - - for key in self.get_projects(): - action = app.actions.ProjectNewRemote.get_QAction(key) - action.setText(key) - self.actions[key] = action - self.addAction(action) - - def get_projects(self): - if not self.remote_projects: - from bw2io.remote import get_projects - ProjectNewTemplateMenu.remote_projects = get_projects() - return self.remote_projects - - -class ViewMenu(QtWidgets.QMenu): - """ - View menu: contains actions in regard to hiding and showing specific UI elements. - """ - - def __init__(self, parent=None) -> None: - super().__init__(parent) - self.setTitle("&View") - - - # Populate pages - self.page_actions = {} - for page_name in app.pages.base_pages.keys(): - action = QtWidgets.QAction(page_name, self) - action.setCheckable(True) - action.triggered.connect(lambda checked, name=page_name: app.main_window.toggle_page(name)) - # Update checked state when menu is about to show - self.page_actions[page_name] = action - self.addAction(action) - - # Update the checked state when menu is about to show - self.aboutToShow.connect(self.update_page_actions) - - self.addSeparator() - - def update_page_actions(self): - """Update the checked state of page actions based on which pages are visible.""" - for page_name, action in self.page_actions.items(): - is_visible = app.main_window.is_page_visible(page_name) - action.setChecked(is_visible) - - -class CalculateMenu(QtWidgets.QMenu): - """ - Calculate Menu: contains actions in regard to calculating the LCA results for the current project - """ - - def __init__(self, parent=None) -> None: - super().__init__(parent) - self.setTitle("&Calculate") - self.cs_actions = [] - - self.new_cs_action = app.actions.CSNew.get_QAction() - self.new_cs_action.setText("New setup...") - self.addAction(self.new_cs_action) - self.addSeparator() - - app.signals.project.changed.connect(self.sync) - app.signals.meta.calculation_setups_changed.connect(self.sync) - - def sync(self): - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - self.cs_actions.clear() - for cs in bd.calculation_setups: - action = app.actions.CSOpen.get_QAction(cs) - action.setText(cs) - self.cs_actions.append(action) - self.addAction(action) - - -class HelpMenu(QtWidgets.QMenu): - """ - Help Menu: contains actions that show info to the user or redirect them to online resources - """ - - def __init__(self, parent=None) -> None: - super().__init__(parent) - self.setTitle("&Help") - - self.addAction( - qicons.ab, "&About Activity Browser", self.about - ) - self.addAction( - "&About Qt", lambda: QtWidgets.QMessageBox.aboutQt(app.main_window) - ) - self.addAction( - qicons.question, "&Get help on the wiki", self.open_wiki - ) - self.addAction( - qicons.issue, "&Report an idea/issue on GitHub", self.raise_issue_github - ) - - def about(self): - """Displays an 'about' window to the user containing e.g. the version of the AB and copyright info""" - # set the window text in html format - text = f""" - Activity Browser - a graphical interface for Brightway2.

- Application version: {version("activity_browser")}
- bw2data version: {version("bw2data")}
- bw2io version: {version("bw2calc")}
- bw2calc version: {version("bw2io")}

- All development happens on github.

- For copyright information please see the copyright on this page.

- For license information please see the copyright on this page.

- """ - - # set up the window - about_window = QtWidgets.QMessageBox(parent=app.main_window) - about_window.setWindowTitle("About the Activity Browser") - about_window.setIconPixmap(qicons.ab.pixmap(QSize(150, 150))) - about_window.setText(text) - - # execute - about_window.exec_() - - def open_wiki(self): - """Opens the AB github wiki in the users default browser""" - url = QUrl( - "https://github.com/LCA-ActivityBrowser/activity-browser/wiki" - ) - QtGui.QDesktopServices.openUrl(url) - - def raise_issue_github(self): - """Opens the github create issue page in the users default browser""" - url = QUrl( - "https://github.com/LCA-ActivityBrowser/activity-browser/issues/new/choose" - ) - QtGui.QDesktopServices.openUrl(url) - - -class ProjectSelectionMenu(QtWidgets.QMenu): - """ - Menu that lists all the projects available through bw2data.projects - """ - def __init__(self, parent=None): - super().__init__(parent) - self.setTitle("Open project") - self.populate() - - self.aboutToShow.connect(self.populate) - self.triggered.connect(lambda act: app.actions.ProjectSwitch.run(act.data())) - - def populate(self): - """ - Populates the menu with the projects available in the database - """ - import bw2data as bd - - # clear the menu of any already existing actions - self.clear() - - # sort projects alphabetically - sorted_projects = sorted(list(bd.projects)) - - # iterate over the sorted projects and add them as actions to the menu - for i, proj in enumerate(sorted_projects): - # check whether the project is BW25 - bw_25 = ( - False if not isinstance(proj.data, dict) else proj.data.get("25", False) - ) - - # create the action and disable it if it's BW25 and BW25 is not supported - action = QtWidgets.QAction(proj.name, self) - action.setData(proj.name) - action.setIcon( - app.application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) if not bw_25 else qicons.empty) - - self.addAction(action) - - -class ImportDatabaseMenu(QtWidgets.QMenu): - def __init__(self, parent=None) -> None: - super().__init__(parent=parent) - self.setTitle("Import database") - self.setIcon(qicons.import_db) - - self.import_from_ecoinvent_action = app.actions.DatabaseImportFromEcoinvent.get_QAction() - self.import_from_excel_action = app.actions.DatabaseImporterExcel.get_QAction() - self.import_from_bw2package_action = app.actions.DatabaseImporterBW2Package.get_QAction() - - self.import_from_ecoinvent_action.setText("ecoinvent...") - self.import_from_excel_action.setText("from .xlsx") - self.import_from_bw2package_action.setText("from .bw2package") - - self.addAction(self.import_from_excel_action) - self.addAction(self.import_from_bw2package_action) - self.addSeparator() - self.addAction(self.import_from_ecoinvent_action) - - -class ExportDatabaseMenu(QtWidgets.QMenu): - def __init__(self, parent=None) -> None: - super().__init__(parent=parent) - self.setTitle("Export database") - - self.export_to_excel_action = app.actions.DatabaseExportExcel.get_QAction() - self.export_to_bw2package_action = app.actions.DatabaseExportBW2Package.get_QAction() - - self.export_to_excel_action.setText("to .xlsx") - self.export_to_bw2package_action.setText("to .bw2package") - - self.addAction(self.export_to_excel_action) - self.addAction(self.export_to_bw2package_action) - - -class ImportICMenu(QtWidgets.QMenu): - def __init__(self, parent=None) -> None: - super().__init__(parent=parent) - self.setTitle("Import impact categories") - self.setIcon(qicons.import_db) - - self.beta_warning = QtWidgets.QWidgetAction(self) - self.beta_warning.setDefaultWidget(QtWidgets.QLabel("Beta features, use at your own risk")) - - self.import_from_ei_excel_action = app.actions.MethodImporterEcoinvent.get_QAction() - self.import_from_bw2io_action = app.actions.MethodImporterBW2IO.get_QAction() - - self.import_from_ei_excel_action.setText("from ecoinvent excel") - self.import_from_bw2io_action.setText("from bw2io") - - self.import_from_ei_excel_action.setIcon(QtGui.QIcon()) - self.import_from_bw2io_action.setIcon(QtGui.QIcon()) - - self.addAction(self.beta_warning) - self.addSeparator() - self.addAction(self.import_from_ei_excel_action) - self.addAction(self.import_from_bw2io_action) diff --git a/activity_browser/app/pages/README.md b/activity_browser/app/pages/README.md deleted file mode 100644 index fda37b35c..000000000 --- a/activity_browser/app/pages/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# pages - -Main content pages displayed in the Activity Browser application. - -## Overview - -This directory contains the primary content pages that users interact with in Activity Browser. Each page represents a major functional area and is displayed in the central widget of the main window. - -## Directory Structure - -- **`activity_details/`** - Activity information display and editing -- **`calculation_setup/`** - Calculation setup configuration and management -- **`impact_category_details/`** - Impact category information and visualization -- **`lca_results/`** - LCA calculation results display and analysis -- **`parameters/`** - Parameter management and scenario configuration -- **`settings/`** - Application settings and preferences - -## Key Files - -- **`welcome.py`** - Welcome page shown when no project is open or on first launch -- **`metadatastore.py`** - Metadata view page (DEBUG only) - -## Two types of pages - -1. **Base pages** - Pages that are initialized once and remain in memory (e.g., Welcome Screen, Parameters, Settings). - - They maintain their state and reload data on project switches. - - Hidden/shown based on user actions or preferences in the settings. - - Defined in `__init__.py`. -2. **Dynamic pages** - Pages that show specific data and are opened as such by the user (e.g. Activity Details, LCA results). - - Created on demand and closed when no longer needed. - - Multiple instances can exist (e.g., multiple activity detail pages) and will be grouped. - -## Development Guidelines - -When creating new pages: - -- Should follow the `PageNamePage` naming convention. -- Set a unique ObjectName for identification. -- Set appropriate tab titles using `setWindowTitle()`. - -## Subdirectory Details - -### `activity_details/` -Display and edit activity information including: -- Basic activity data (name, location, unit, etc.) -- Exchanges (inputs/outputs) -- Parameters and formulas -- Metadata and classifications - -### `calculation_setup/` -Configure and manage calculation setups: -- Reference flows (functional units) -- Impact assessment methods -- Scenario selections -- Calculation execution - -### `impact_category_details/` -Show impact category information: -- Characterization factors -- Method hierarchy -- Method metadata - -### `lca_results/` -Display LCA calculation results: -- Impact scores -- Contribution analyses -- Sankey diagrams -- Graph visualizations -- Export options - -### `parameters/` -[BASE PAGE] - -Manage parameters and scenarios: -- Project parameters -- Database parameters -- Activity parameters -- Parameter formulas -- Scenario management - -### `settings/` -[BASE PAGE] - -Application configuration: -- General preferences -- Project settings -- Plugin configuration -- Import/export settings diff --git a/activity_browser/app/pages/__init__.py b/activity_browser/app/pages/__init__.py deleted file mode 100644 index 1f360e945..000000000 --- a/activity_browser/app/pages/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from .activity_details import ActivityDetailsPage -from .welcome import WelcomePage -from .calculation_setup import CalculationSetupPage -from .impact_category_details import ImpactCategoryDetailsPage -from .lca_results import LCAResultsPage -from .parameters import ParametersPage -from .metadatastore import MetaDataStorePage -from .settings import SettingsPage - -base_pages = { - "Welcome": WelcomePage, - "Parameters": ParametersPage, - "Settings": SettingsPage, -} diff --git a/activity_browser/app/pages/activity_details/__init__.py b/activity_browser/app/pages/activity_details/__init__.py deleted file mode 100644 index 76985c63a..000000000 --- a/activity_browser/app/pages/activity_details/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .activity_details import ActivityDetailsPage \ No newline at end of file diff --git a/activity_browser/app/pages/activity_details/activity_details.py b/activity_browser/app/pages/activity_details/activity_details.py deleted file mode 100644 index 5b2ae11e6..000000000 --- a/activity_browser/app/pages/activity_details/activity_details.py +++ /dev/null @@ -1,169 +0,0 @@ -from loguru import logger - -from qtpy import QtCore, QtWidgets - -import bw2data as bd - -from activity_browser import app -from activity_browser.bwutils.commontasks import refresh_node_or_none -from activity_browser.ui import widgets - -from .activity_header import ActivityHeader -from .exchanges_tab import ExchangesTab -from .description_tab import DescriptionTab -from .graph_tab import GraphTab -from .parameters_tab import ParametersTab -from .data_tab import DataTab -from .consumers_tab import ConsumersTab - - -class ActivityDetailsPage(QtWidgets.QWidget): - """ - A widget that displays detailed information about a specific activity. - - Attributes: - activity (tuple | int | bd.Node): The activity to display details for. - activity_data_grid (ActivityHeader): The header widget displaying activity data. - tabs (QtWidgets.QTabWidget): The tab widget containing various detail tabs. - exchanges_tab (ExchangesTab): The tab displaying exchanges related to the activity. - description_tab (DescriptionTab): The tab displaying the description of the activity. - graph_explorer (GraphTab): The tab displaying the graph related to the activity. - parameters_tab (ParametersTab): The tab displaying parameters of the activity. - consumer_tab (ConsumersTab): The tab displaying consumers of the activity. - data_tab (DataTab): The tab displaying data related to the activity. - """ - _populate_later_flag = False - - def __init__(self, activity: tuple | int | bd.Node, parent=None): - """ - Initializes the ActivityDetailsPage widget. - - Args: - activity (tuple | int | bd.Node): The activity to display details for. - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(parent) - self.activity = bd.get_activity(activity) - self.setObjectName(f"activity_details_{self.activity['database']}_{self.activity['code']}") - self.setWindowTitle(self.activity["name"]) - - # Initialize header widget for activity data - self.activity_data_grid = ActivityHeader(self) - # Initialize tab widget to hold various detail tabs - self.tabs = QtWidgets.QTabWidget(self) - - # Initialize and add the Exchanges tab - self.exchanges_tab = ExchangesTab(activity, self) - self.tabs.addTab(self.exchanges_tab, "Exchanges") - - # Initialize and add the Description tab - self.description_tab = DescriptionTab(activity, self) - self.tabs.addTab(self.description_tab, "Description") - - # Initialize and add the Graph tab - self.graph_explorer = GraphTab(activity, self) - self.tabs.addTab(self.graph_explorer, "Graph") - - # Initialize and add the Parameters tab - self.parameters_tab = ParametersTab(activity, self) - self.tabs.addTab(self.parameters_tab, "Parameters") - - # Initialize and add the Consumers tab - self.consumer_tab = ConsumersTab(activity, self) - self.tabs.addTab(self.consumer_tab, "Consumers") - - # Initialize and add the Data tab - self.data_tab = DataTab(activity, self) - self.tabs.addTab(self.data_tab, "Data") - - # Build the layout of the widget - self.build_layout() - # Synchronize the widget with the current state of the activity - self.sync() - # Connect signals to their respective slots - self.connect_signals() - - def build_layout(self): - """ - Builds the layout of the widget. - """ - layout = QtWidgets.QVBoxLayout() - layout.setContentsMargins(10, 10, 4, 1) - layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) - - # Add the activity data grid and tabs to the layout - layout.addWidget(self.activity_data_grid) - layout.addWidget(widgets.ABHLine(self)) - layout.addWidget(self.tabs) - - self.setLayout(layout) - - def connect_signals(self): - """ - Connects signals to their respective slots. - """ - app.signals.node.deleted.connect(self.on_node_deleted) - app.signals.database.deleted.connect(self.on_database_deleted) - app.signals.meta.databases_changed.connect(self.syncLater) - app.signals.parameter.recalculated.connect(self.syncLater) - app.signals.node.changed.connect(self.syncLater) - - def on_node_deleted(self, node): - """ - Slot to handle node deletion. - - Args: - node: The node that was deleted. - """ - if node.id == self.activity.id: - self.deleteLater() - - def on_database_deleted(self, name): - """ - Slot to handle database deletion. - - Args: - name: The name of the database that was deleted. - """ - if name == self.activity["database"]: - self.deleteLater() - - def syncLater(self): - """ - Schedules a sync operation to be performed later. - """ - - def slot(): - self._populate_later_flag = False - self.sync() - self.thread().eventDispatcher().awake.disconnect(slot) - - if self._populate_later_flag: - return - - self._populate_later_flag = True - self.thread().eventDispatcher().awake.connect(slot) - - def sync(self): - """ - Synchronizes the widget with the current state of the activity. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - self.activity = refresh_node_or_none(self.activity) - - if self.activity is None: - # Activity was already deleted - return - - # Update the tab name to be the activity name - self.setWindowTitle(self.activity["name"]) - - # Synchronize all tabs with the current state of the activity - self.activity_data_grid.sync() - self.exchanges_tab.sync() - self.description_tab.sync() - self.consumer_tab.sync() - self.data_tab.sync() - self.parameters_tab.sync() - self.graph_explorer.sync() diff --git a/activity_browser/app/pages/activity_details/activity_header.py b/activity_browser/app/pages/activity_details/activity_header.py deleted file mode 100644 index c9828ef82..000000000 --- a/activity_browser/app/pages/activity_details/activity_header.py +++ /dev/null @@ -1,310 +0,0 @@ -from qtpy import QtWidgets, QtCore -from loguru import logger - -import bw2data as bd -import bw_functional as bf - -from activity_browser import app -from activity_browser.bwutils.commontasks import refresh_node, database_is_locked -from activity_browser.ui import widgets - - -class ActivityHeader(QtWidgets.QWidget): - """ - A widget that displays the header information of a specific activity. - - Attributes: - DATABASE_DEFINED_ALLOCATION (str): Constant for database default allocation. - CUSTOM_ALLOCATION (str): Constant for custom allocation. - activity (bd.Node): The activity to display the header for. - """ - DATABASE_DEFINED_ALLOCATION = "(database default)" - CUSTOM_ALLOCATION = "Custom..." - - def __init__(self, parent: QtWidgets.QWidget): - """ - Initializes the ActivityHeader widget. - - Args: - parent (QtWidgets.QWidget): The parent widget. - """ - super().__init__(parent) - self.activity = parent.activity - - layout = QtWidgets.QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - - def sync(self): - """ - Synchronizes the widget with the current state of the activity. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - self.activity = refresh_node(self.activity) - - self.clear_layout() - - if database_is_locked(self.activity["database"]): - self.layout().addWidget(LockedWarningBar(self)) - - self.layout().addLayout(self.build_grid()) - - def clear_layout(self, layout: QtWidgets.QLayout = None): - layout = layout or self.layout() - - if layout is None: - return - - while layout.count(): - item = layout.takeAt(0) - widget = item.widget() - if widget is not None: - widget.deleteLater() - elif item.layout() is not None: - self.clear_layout(item.layout()) - - def build_grid(self) -> QtWidgets.QGridLayout: - grid = QtWidgets.QGridLayout(self) - grid.setContentsMargins(0, 5, 0, 5) - grid.setSpacing(10) - grid.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) - - db_locked = database_is_locked(self.activity["database"]) - setup = self.disabled_setup() if db_locked else self.enabled_setup() - - # Arrange widgets for display as a grid - for i, (title, widget) in enumerate(setup): - grid.addWidget(widgets.ABLabel.demiBold(title, self), i, 1) - grid.addWidget(widget, i, 2, 1, 4) - - return grid - - def enabled_setup(self): - setup = [ - ("Name:", ActivityName(self),), - ("Location:", ActivityLocation(self),), - ] - - if isinstance(self.activity, bf.Process): - setup.append(("Properties:", ActivityProperties(self),),) - - # Add allocation strategy selector if the activity is multifunctional - if self.activity.get("type") == "multifunctional": - setup.append(("Allocation:", ActivityAllocation(self),)) - - return setup - - def disabled_setup(self): - setup = [ - ("Name:", QtWidgets.QLabel(self.activity.get("name", "unspecified"), self),), - ("Location:", QtWidgets.QLabel(self.activity.get("location", "unspecified"), self),), - ] - - if isinstance(self.activity, bf.Process): - props = self.activity.available_properties() - prop_text = ", ".join(props) if props else "None" - setup.append(("Properties:", QtWidgets.QLabel(prop_text, self),)) - - # Add allocation strategy selector if the activity is multifunctional - if self.activity.get("type") == "multifunctional": - setup.append(("Allocation:", QtWidgets.QLabel(self.activity.get("allocation", "unspecified"), self),),) - - return setup - - - - - -class ActivityName(QtWidgets.QLineEdit): - """ - A widget that displays and edits the name of the activity. - """ - - def __init__(self, parent: ActivityHeader): - """ - Initializes the ActivityName widget. - - Args: - parent (ActivityHeader): The parent widget. - """ - super().__init__(parent.activity["name"], parent) - self.editingFinished.connect(self.change_name) - - def change_name(self): - """ - Changes the name of the activity if it has been modified. - """ - if self.text() == self.parent().activity["name"]: - return - app.actions.ActivityModify.run(self.parent().activity, "name", self.text()) - - -class ActivityLocation(QtWidgets.QLineEdit): - """ - A widget that displays and edits the location of the activity. - """ - - def __init__(self, parent: ActivityHeader): - """ - Initializes the ActivityLocation widget. - - Args: - parent (ActivityHeader): The parent widget. - """ - super().__init__(parent.activity.get("location"), parent) - self.editingFinished.connect(self.change_location) - - locations = set(app.metadata.dataframe.get("location", ["GLO"])) - completer = QtWidgets.QCompleter(locations, self) - self.setCompleter(completer) - - def change_location(self): - """ - Changes the location of the activity if it has been modified. - """ - if self.text() == self.parent().activity.get("location"): - return - app.actions.ActivityModify.run(self.parent().activity, "location", self.text()) - - -class ActivityProperties(QtWidgets.QWidget): - """ - A widget that displays and edits the properties of the activity. - """ - - def __init__(self, parent: ActivityHeader): - """ - Initializes the ActivityProperties widget. - - Args: - parent (ActivityHeader): The parent widget. - """ - super().__init__(parent) - - self.setContentsMargins(0, 0, 0, 0) - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - - if not isinstance(parent.activity, bf.Process): - return - - for property_name in parent.activity.available_properties(): - layout.addWidget(ActivityProperty(parent.activity, property_name)) - - add_label = QtWidgets.QLabel("Add property") - add_label.mouseReleaseEvent = lambda x: app.actions.ProcessPropertyModify.run(parent.activity) - - layout.addWidget(add_label) - - layout.addStretch(1) - - -class ActivityProperty(QtWidgets.QPushButton): - """ - A widget that represents a single property of the activity. - """ - - def __init__(self, activity, property_name): - """ - Initializes the ActivityProperty widget. - - Args: - activity (bd.Node): The activity to which the property belongs. - property_name (str): The name of the property. - """ - super().__init__(property_name, None) - - self.modify_action = app.actions.ProcessPropertyModify.get_QAction(activity, property_name) - self.remove_action = app.actions.ProcessPropertyRemove.get_QAction(activity, property_name) - - self.menu = QtWidgets.QMenu(self) - self.menu.addAction(self.modify_action) - self.menu.addAction(self.remove_action) - - self.setStyleSheet(""" - QPushButton { - border: 1px solid #8f8f91; - border-radius: 0px; - padding: 1px 10px 1px 10px; - min-width: 0px; - } - """) - - def mouseReleaseEvent(self, e): - """ - Handles the mouse release event to show the context menu. - - Args: - e: The mouse release event. - """ - pos = self.geometry().bottomLeft() - pos = self.parent().mapToGlobal(pos) - self.menu.exec_(pos) - e.accept() - - -class ActivityAllocation(QtWidgets.QComboBox): - """ - A widget that displays and edits the allocation strategy of the activity. - """ - - def __init__(self, parent: ActivityHeader): - """ - Initializes the ActivityAllocation widget. - - Args: - parent (ActivityHeader): The parent widget. - """ - if not isinstance(parent.activity, bf.Process): - raise TypeError("ActivityAllocation can only be used with bf.Process instances.") - - super().__init__(parent) - - self.addItems(sorted(bf.allocation_strategies)) - if props := parent.activity.available_properties(): - self.insertSeparator(1000) # Large number to make sure it's appended at the end - self.addItems(sorted(props)) - - i = self.findText(parent.activity.get("allocation")) - self.setCurrentIndex(i) - - self.currentTextChanged.connect(self.change_allocation) - - def change_allocation(self, allocation: str): - """ - Changes the allocation strategy of the activity if it has been modified. - - Args: - allocation (str): The new allocation strategy. - """ - act = self.parent().activity - if act.get("allocation") == allocation: - return - app.actions.ActivityModify.run(act, "allocation", allocation) - - -class LockedWarningBar(QtWidgets.QToolBar): - def __init__(self, parent: ActivityHeader): - super().__init__(parent) - self.setMovable(False) - self.setContentsMargins(0, 0, 0, 0) - - warning_label = QtWidgets.QLabel("The database of this activity is currently locked.") - height = warning_label.minimumSizeHint().height() - - warning_icon = QtWidgets.QLabel(self) - qicon = app.application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) - pixmap = qicon.pixmap(height, height) - warning_icon.setPixmap(pixmap) - - migrate_label = QtWidgets.QLabel("Unlock database") - migrate_label.mouseReleaseEvent = lambda x: app.actions.DatabaseSetReadonly.run(parent.activity["database"], False) - - self.addWidget(warning_icon) - self.addWidget(warning_label) - self.addWidget(migrate_label) - - def contextMenuEvent(self, event): - return None - diff --git a/activity_browser/app/pages/activity_details/consumers_tab.py b/activity_browser/app/pages/activity_details/consumers_tab.py deleted file mode 100644 index 02546bded..000000000 --- a/activity_browser/app/pages/activity_details/consumers_tab.py +++ /dev/null @@ -1,163 +0,0 @@ -from qtpy import QtWidgets -from loguru import logger - -import pandas as pd -import bw2data as bd -import bw_functional as bf - -from activity_browser import app -from activity_browser.bwutils.commontasks import refresh_node -from activity_browser.ui import widgets, icons, core - - -class ConsumersTab(QtWidgets.QWidget): - """ - A widget that displays consumers related to a specific activity. - - Attributes: - activity (tuple | int | bd.Node): The activity to display consumers for. - view (ConsumersView): The view displaying the consumers. - model (ConsumersModel): The model containing the data for the consumers. - """ - def __init__(self, activity: tuple | int | bd.Node, parent=None): - """ - Initializes the ConsumersTab widget. - - Args: - activity (tuple | int | bd.Node): The activity to display consumers for. - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(parent) - - self.activity = refresh_node(activity) - - self.view = ConsumersView(self) - self.model = ConsumersModel(parent=self, enable_sorting=True) - self.view.setModel(self.model) - self.view.setSortingEnabled(True) - - self.build_layout() - self.sync() - - def build_layout(self): - """ - Builds the layout of the widget. - """ - layout = QtWidgets.QVBoxLayout() - layout.setContentsMargins(0, 10, 0, 1) - layout.addWidget(self.view) - self.setLayout(layout) - - def sync(self): - """ - Synchronizes the widget with the current state of the activity. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - self.activity = refresh_node(self.activity) - exchanges = [] - if isinstance(self.activity, bf.Process): - for product in self.activity.products(): - exchanges += list(product.upstream()) - else: - exchanges = list(self.activity.upstream()) - - df = self.build_df(exchanges) - df.reset_index(drop=True, inplace=True) - self.model.set_dataframe(df) - - def build_df(self, exchanges: list[bd.Edge]) -> pd.DataFrame: - """ - Builds a DataFrame from the given exchanges. - - Args: - exchanges (list): The list of exchanges to build the DataFrame from. - - Returns: - pd.DataFrame: The DataFrame containing the exchanges data. - """ - exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "output"]) - input_df = app.metadata.get_metadata(exc_df["input"].unique(), ["name", "type", "unit", "key"]) - output_df = app.metadata.get_metadata(exc_df["output"].unique(), ["name", "type", "key"]) - - df = exc_df.merge( - input_df.rename({"name": "product", "type": "_product_type"}, axis="columns"), - left_on="input", - right_on="key", - ).drop(columns=["key"]) - - df = df.merge( - output_df.rename({"name": "consumer", "type": "_consumer_type"}, axis="columns"), - left_on="output", - right_on="key", - ).drop(columns=["key"]) - - df = df.rename({"input": "_product_key", "output": "_consumer_key"}, axis="columns") - - cols = ["amount", "unit", "product", "consumer"] - cols += [col for col in df.columns if col.startswith("_")] - - return df[cols] - - -class ConsumersView(widgets.ABTreeView): - """ - A view that displays the consumers in a tree structure. - """ - def mouseDoubleClickEvent(self, event) -> None: - """ - Handles the mouse double-click event. - - Args: - event: The mouse event. - """ - indexes = self.selectedIndexes() - if not indexes: - return super().mouseDoubleClickEvent(event) - - keys = self.model().values_from_indices("_consumer_key", indexes) - if keys: - app.actions.ActivityOpen.run(keys) - - -class ConsumersModel(core.ABTreeModel): - """ - A model representing the data for the consumers. - """ - - def decorationData(self, index): - """ - Provides decoration data for the model. - - Args: - index: The index for which to provide decoration data. - - Returns: - The decoration data for the model. - """ - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return None - - if column_name not in ["product", "consumer"]: - return None - - if column_name == "product": - activity_type = row.get("_product_type") - else: # column_name == "consumer" - activity_type = row.get("_consumer_type") - - if activity_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: - return icons.qicons.biosphere - if activity_type == "processwithreferenceproduct": - return icons.qicons.processproduct - if activity_type == "product": - return icons.qicons.product - if activity_type in ["process", "multifunctional", "nonfunctional"]: - return icons.qicons.process - if activity_type == "waste": - return icons.qicons.waste - - return None diff --git a/activity_browser/app/pages/activity_details/data_tab.py b/activity_browser/app/pages/activity_details/data_tab.py deleted file mode 100644 index b9d2da129..000000000 --- a/activity_browser/app/pages/activity_details/data_tab.py +++ /dev/null @@ -1,189 +0,0 @@ -from qtpy import QtWidgets, QtCore -from loguru import logger - -import pandas as pd -import bw2data as bd -import bw_functional as bf - -from activity_browser import app -from activity_browser.bwutils.commontasks import refresh_node, database_is_locked -from activity_browser.ui import widgets, delegates, core - - -class DataTab(QtWidgets.QWidget): - """ - A widget that displays the data structure of a specific activity. - - Attributes: - activity (tuple | int | bd.Node): The activity to display data for. - data_view (DataView): The view displaying the data. - data_model (DataModel): The model containing the data. - """ - def __init__(self, activity: tuple | int | bd.Node, parent=None): - """ - Initializes the DataTab widget. - - Args: - activity (tuple | int | bd.Node): The activity to display data for. - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(parent) - - self.activity = refresh_node(activity) - - # Data TreeView - self.data_view = DataView(self) - self.data_model = DataModel(parent=self) - self.data_view.setModel(self.data_model) - - df = self.build_df() - df.reset_index(drop=True, inplace=True) - self.data_model.set_dataframe(df) - self.data_model.group(["_name"]) - self.data_view.expandAll() - - self.build_layout() - - def build_layout(self): - """ - Builds the layout of the widget. - """ - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.data_view) - self.setLayout(layout) - - def sync(self) -> None: - """ - Synchronizes the widget with the current state of the activity. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - self.activity = refresh_node(self.activity) - df = self.build_df() - df.reset_index(drop=True, inplace=True) - self.data_model.set_dataframe(df) - self.data_model.group(["_name"]) - self.data_view.expandAll() - - def build_df(self) -> pd.DataFrame: - """ - Builds a DataFrame from the activity data. - - Returns: - pd.DataFrame: The DataFrame containing the activity data. - """ - df = pd.Series(self.activity.as_dict()).to_frame() - df["_name"] = f"{self.activity['name']} {df.get('product', '')} ({self.activity['id']})" - df["_activity_id"] = self.activity.id - df["_activity_db"] = self.activity["database"] - - if isinstance(self.activity, bf.Process): - for product in self.activity.products(): - fn_df = pd.DataFrame.from_dict(product.as_dict(), orient="index") - fn_df["_name"] = f"{product['name']}: {product.get('product', '')} ({product['id']})" - fn_df["_activity_id"] = product.id - fn_df["_activity_db"] = product["database"] - df = pd.concat([df, fn_df]) - - df = df.reset_index() - df = df.rename({"index": "field", 0: "value"}, axis=1) - df = df.sort_values(["_name", "field"], ignore_index=True) - - cols = ["field", "value", "_name", "_activity_id", "_activity_db"] - return df[cols] - - -class DataView(widgets.ABTreeView): - """ - A view that displays the data in a tree structure. - - Attributes: - defaultColumnDelegates (dict): The default column delegates for the view. - """ - defaultColumnDelegates = { - "field": delegates.StringDelegate, - "value": delegates.NewFormulaDelegate, - } - - -class DataModel(core.ABTreeModel): - """ - A model representing the data for the activity. - """ - - def setData(self, index: QtCore.QModelIndex, value, role: int = QtCore.Qt.ItemDataRole.EditRole) -> bool: - """ - Sets the data for the given index. - - Args: - index (QtCore.QModelIndex): The index to set data for. - value: The value to set. - role (int): The role for which to set the data. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if role != QtCore.Qt.ItemDataRole.EditRole: - return False - - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return False - - if column_name == "value": - value = eval(value) - app.actions.ActivityModify.run(row.get("_activity_id"), row.get("field"), value) - return True - - return False - - def indexEditable(self, index: QtCore.QModelIndex) -> bool: - """ - Returns whether the index is editable. - - Args: - index (QtCore.QModelIndex): The index to check. - - Returns: - bool: True if the index is editable, False otherwise. - """ - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return False - - if column_name == "value" and not database_is_locked(row.get("_activity_db")): - return True - - return False - - def displayData(self, index: QtCore.QModelIndex) -> any: - """ - Provides display data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide display data. - - Returns: - The display data for the index. - """ - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - # Branch node - node = index.internalPointer() - if isinstance(node, core.TreeNode): - return node.path[-1] if index.column() == 0 else None - return None - - if column_name == "value": - data = row.get(column_name) - if isinstance(data, str): - return f"'{data}'" - return str(data) - - return row.get(column_name) diff --git a/activity_browser/app/pages/activity_details/description_tab.py b/activity_browser/app/pages/activity_details/description_tab.py deleted file mode 100644 index c5605c7bb..000000000 --- a/activity_browser/app/pages/activity_details/description_tab.py +++ /dev/null @@ -1,51 +0,0 @@ -from qtpy import QtWidgets, QtGui -from loguru import logger - -import bw2data as bd - -from activity_browser import app -from activity_browser.bwutils.commontasks import refresh_node, database_is_locked - - -class DescriptionTab(QtWidgets.QTextEdit): - """ - A widget that displays and edits the description (comment) of a specific activity. - - Attributes: - activity (tuple | int | bd.Node): The activity to display and edit the description for. - """ - def __init__(self, activity: tuple | int | bd.Node, parent=None): - """ - Initializes the DescriptionTab widget. - - Args: - activity (tuple | int | bd.Node): The activity to display and edit the description for. - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - self.activity = refresh_node(activity) - super().__init__(parent, self.activity.get("comment", "")) - self.setPlaceholderText("Click here to edit the description of this activity...") - - def sync(self): - """ - Synchronizes the widget with the current state of the activity. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - self.activity = refresh_node(self.activity) - self.setText(self.activity.get("comment", "")) - self.moveCursor(QtGui.QTextCursor.MoveOperation.End) - - # Set the read-only state based on the activity's database - self.setReadOnly(database_is_locked(self.activity["database"])) - - def focusOutEvent(self, e): - """ - Handles the focus out event to save the comment if it has changed. - - Args: - e: The focus out event. - """ - if self.toPlainText() == self.activity.get("comment", ""): - return - app.actions.ActivityModify.run(self.activity, "comment", self.toPlainText()) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py deleted file mode 100644 index 91f67620e..000000000 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ /dev/null @@ -1,834 +0,0 @@ -from PySide6.QtCore import QModelIndex -from loguru import logger -from typing import Literal - -from qtpy import QtWidgets, QtGui, QtCore -from qtpy.QtCore import Qt - -import pandas as pd -import bw2data as bd - -import bw_functional as bf - -from activity_browser import app -from activity_browser.bwutils.commontasks import (refresh_node, database_is_locked, database_is_legacy, - is_node_product_or_waste, is_node_biosphere, parameters_in_scope, - is_node_product, is_node_waste) -from activity_browser.ui import widgets, icons, delegates, core - - - -EXCHANGE_MAP = { - "natural resource": "biosphere", "emission": "biosphere", "inventory indicator": "biosphere", - "economic": "biosphere", "social": "biosphere", "product": "technosphere", - "processwithreferenceproduct": "technosphere", "waste": "technosphere", -} - - -class ExchangesTab(QtWidgets.QWidget): - """ - A widget that displays exchanges related to a specific activity. - - Attributes: - activity (tuple | int | bd.Node): The activity to display exchanges for. - output_view (ExchangesView): The view displaying the output exchanges. - output_model (ExchangesModel): The model containing the data for the output exchanges. - input_view (ExchangesView): The view displaying the input exchanges. - input_model (ExchangesModel): The model containing the data for the input exchanges. - """ - def __init__(self, activity: tuple | int | bd.Node, parent=None): - """ - Initializes the ExchangesTab widget. - - Args: - activity (tuple | int | bd.Node): The activity to display exchanges for. - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(parent) - self.setAcceptDrops(True) - - # Refresh the activity node - self.activity = refresh_node(activity) - - # Output Table - self.output_view = ExchangesView(self) - self.output_model = ExchangesModel(tab=self) - self.output_view.setModel(self.output_model) - - # Set indentation for output view - self.output_view.setIndentation(0) - - # Input Table - self.input_view = ExchangesView(self) - self.input_model = ExchangesModel(tab=self) - self.input_view.setModel(self.input_model) - - # Set indentation for input view - self.input_view.setIndentation(0) - - # Overlay for drag and drop - self.overlay = None - - # Build the layout of the widget - self.build_layout() - - def build_layout(self): - """ - Builds the layout of the widget. - """ - # Add output label and view to the layout - output = QtWidgets.QWidget(self) - output_layout = QtWidgets.QVBoxLayout(output) - output_layout.addWidget(widgets.ABLabel.demiBold(" Output:", self)) - output_layout.addWidget(self.output_view) - - # Add input label and view to the layout - input = QtWidgets.QWidget(self) - input_layout = QtWidgets.QVBoxLayout(input) - input_layout.addWidget(widgets.ABLabel.demiBold(" Input:", self)) - input_layout.addWidget(self.input_view) - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 10, 0, 1) - splitter = QtWidgets.QSplitter(Qt.Orientation.Vertical, self, childrenCollapsible=False) - splitter.addWidget(output) - splitter.addWidget(input) - layout.addWidget(splitter) - - def sync(self) -> None: - """ - Synchronizes the widget with the current state of the activity. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - # Refresh the activity node - self.activity = refresh_node(self.activity) - - # Get the production, technosphere, and biosphere exchanges - production = self.activity.production() - technosphere = self.activity.technosphere() - biosphere = self.activity.biosphere() - substitution = self.activity.substitution() - - # Filter inputs and outputs based on the amount and type - inputs = ([x for x in production if x["amount"] < 0] + - [x for x in technosphere if x["amount"] >= 0] + - [x for x in biosphere if (x.input["type"] != "emission" and x["amount"] >= 0) or (x.input["type"] == "emission" and x["amount"] < 0)] + - [x for x in substitution if x["amount"] < 0] - ) - - outputs = ([x for x in production if x["amount"] >= 0] + - [x for x in technosphere if x["amount"] < 0] + - [x for x in biosphere if (x.input["type"] == "emission" and x["amount"] >= 0) or (x.input["type"] != "emission" and x["amount"] < 0)] + - [x for x in substitution if x["amount"] >= 0] - ) - - # Update the models with the new data - output_df = self.build_df(outputs) - output_df.reset_index(drop=True, inplace=True) - self.output_model.set_dataframe(output_df) - self.output_view.drag_drop_hint.setVisible(output_df.empty) - - input_df = self.build_df(inputs) - input_df.reset_index(drop=True, inplace=True) - self.input_model.set_dataframe(input_df) - self.input_view.drag_drop_hint.setVisible(input_df.empty) - - def build_df(self, exchanges) -> pd.DataFrame: - """ - Builds a DataFrame from the given exchanges. - - Args: - exchanges (list): The list of exchanges to build the DataFrame from. - - Returns: - pd.DataFrame: The DataFrame containing the exchanges data. - """ - # Define the columns for the metadata - cols = ["key", "unit", "name", "product", "location", "database", "allocation_factor", - "properties", "processor", "categories", "type"] - - # Create a DataFrame from the exchanges - exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "formula", "comment", "type"]) - exc_df["uncertainty"] = [x.uncertainty for x in exchanges] - act_df = app.metadata.get_metadata(exc_df["input"].unique(), cols).rename(columns={"type": "_producer_type"}) - - # Merge the exchanges DataFrame with the metadata DataFrame - df = exc_df.merge( - act_df, - left_on="input", - right_on="key" - ).drop(columns=["key"]) - - # Set allocation_factor to NA for non-production exchanges - df.loc[df["type"] != "production", "allocation_factor"] = pd.NA - - # Handle properties data if available - if not act_df.properties.isna().all(): - props_df = act_df[act_df.properties.notna()] - props_df = pd.DataFrame(list(props_df.get("properties")), index=props_df.key) - props_df.rename(lambda col: f"property_{col}", axis="columns", inplace=True) - - df = df.merge( - props_df, - left_on="input", - right_index=True, - how="left", - ) - - # Add allocation and activity type information - df["_allocate_by"] = self.activity.get("allocation") - df["_activity_type"] = self.activity.get("type") - df["_exchange"] = exchanges - - # Drop the properties column and rename some columns - df.drop(columns=["properties"], inplace=True) - df.rename({ - "input": "_input_key", - "processor": "_processor_key", - "type": "_exchange_type", - "name": "producer", - }, axis="columns", inplace=True) - - # Define the order of columns for the final DataFrame - cols = ["amount", "unit", "product", "producer", "location", "categories", "database"] - cols += ["allocation_factor"] if not database_is_legacy(self.activity.get("database")) else [] - cols += [col for col in df.columns if col.startswith("property")] - cols += ["formula", "comment", "uncertainty"] - cols += [col for col in df.columns if col.startswith("_")] - - return df[cols] - - def dragEnterEvent(self, event): - """ - Handles the drag enter event. - - Args: - event: The drag enter event. - """ - if database_is_locked(self.activity["database"]): - return - - has_nodes = event.mimeData().hasFormat("application/bw-nodekeylist") - has_exchanges = event.mimeData().hasFormat("application/bw-exchangelist") - - if not has_nodes and not has_exchanges: - return - - event.accept() - action = self.action_from_mime(event.mimeData()) - - self.input_view.overlay.show() - self.output_view.overlay.show() - - if action == "product": - self.output_view.overlay.setText("Drop to substitute production") - self.input_view.overlay.setText("Drop to consume product") - return - - if action == "waste": - self.output_view.overlay.setText("Drop to produce waste") - self.input_view.overlay.setText("Drop to substitute waste consumption") - return - - if action == "resource": - self.output_view.overlay.hide() - self.input_view.overlay.setText("Drop to consume natural resource") - return - - if action == "emission": - self.input_view.overlay.hide() - self.output_view.overlay.setText("Drop to emit to environment") - return - - - def dragMoveEvent(self, event): - """ - Handles the drag move event to adjust overlay opacity based on hover position. - - Args: - event: The drag move event. - """ - has_nodes = event.mimeData().hasFormat("application/bw-nodekeylist") - has_exchanges = event.mimeData().hasFormat("application/bw-exchangelist") - - if not has_nodes and not has_exchanges: - return - - if self.input_view.overlay.hovering(): - self.input_view.overlay.setOpacity("high") - self.output_view.overlay.setOpacity("medium") - elif self.output_view.overlay.hovering(): - self.output_view.overlay.setOpacity("high") - self.input_view.overlay.setOpacity("medium") - else: - self.input_view.overlay.setOpacity("medium") - self.output_view.overlay.setOpacity("medium") - event.ignore() - return - - event.accept() - - def dragLeaveEvent(self, event): - """ - Handles the drag leave event. - - Args: - event: The drag leave event. - """ - # Reset the palette on drag leave - self.input_view.overlay.hide() - self.output_view.overlay.hide() - - def dropEvent(self, event): - """ - Handles the drop event. - - Args: - event: The drop event. - """ - logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") - - self.input_view.overlay.hide() - self.output_view.overlay.hide() - - output = self.output_view.overlay.hovering() - keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - - positive_exchanges = {"technosphere": set(), "biosphere": set(), "substitution": set()} - negative_exchanges = {"technosphere": set(), "substitution": set()} - - for key in keys: - exc_type = get_exchange_type(key, output=output) - if exc_type is None: - continue - if exc_type.startswith("-"): - negative_exchanges[exc_type[1:]].add(key) - else: - positive_exchanges[exc_type].add(key) - - # Run the action for new exchanges - for exc_type, keys in positive_exchanges.items(): - app.actions.ExchangeNew.run(keys, self.activity.key, exc_type) - for exc_type, keys in negative_exchanges.items(): - app.actions.ExchangeNew.run(keys, self.activity.key, exc_type, amount=-1) - - def action_from_mime(self, mime: core.ABMimeData) -> Literal["product", "waste", "resource", "emission", "generic"]: - """ - Determines the appropriate action based on the mime data. - - Args: - mime (core.ABMimeData): The mime data. - - """ - keys = mime.retrievePickleData("application/bw-nodekeylist") - data = app.metadata.get_metadata(keys, ["type"]) - data = set(data["type"].unique()) - data.discard("process") - data.discard("multifunctional") - data.discard("nonfunctional") - - if len(data) != 1: - return "generic" - - node_type = data.pop() - if node_type in ["product", "processwithreferenceproduct"]: - return "product" - if node_type == "waste": - return "waste" - if node_type == "natural resource": - return "resource" - if node_type == "emission": - return "emission" - else: - return "generic" - -def get_exchange_type(activity_key: tuple, output=False) -> str | None: - if is_node_product(activity_key): - return "substitution" if output else "technosphere" - if is_node_waste(activity_key): - return "-technosphere" if output else "-substitution" - elif is_node_biosphere(activity_key): - return "biosphere" - return None - - -class RelinkDelegate(delegates.StringDelegate): - matched: pd.DataFrame - - def createEditor(self, parent, option, index): - model: ExchangesModel = index.model() - - column = model.column_name(index) - column = "name" if column == "producer" else column - - if column == "product" and model.functional(index): - return super().createEditor(parent, option, index) - - row = model.row(index) - - setup = { - "database": row["database"], - "name": row["producer"], - "product": row["product"], - "categories": row["categories"], - "location": row["location"], - "type": row["_producer_type"], - } - - del setup[column] # remove the column being edited because we are looking for alternatives - - self.matched = app.metadata.match(**setup) - - combo = QtWidgets.QComboBox(parent) - combo.addItems(list(self.matched.get(column, []).astype(str))) - return combo - - def setEditorData(self, editor: QtWidgets.QComboBox, index): - model: ExchangesModel = index.model() - column = model.column_name(index) - column = "name" if column == "producer" else column - - if column == "product" and model.functional(index): - return super().setEditorData(editor, index) - - value = index.data() - if value: - i = editor.findText(str(value)) - if i >= 0: - editor.setCurrentIndex(i) - - def setModelData(self, editor: QtWidgets.QComboBox, model, index): - model: ExchangesModel = index.model() - column = model.column_name(index) - column = "name" if column == "producer" else column - - if column == "product" and model.functional(index): - return super().setModelData(editor, model, index) - - choice = editor.currentIndex() - key = self.matched.iloc[choice].key - row = model.row(index) - - app.actions.ExchangeModify.run( - row.get("_exchange"), - {"input": key} - ) - - -class ExchangesView(widgets.ABTreeView): - """ - A view that displays the exchanges in a tree structure. - - Attributes: - defaultColumnDelegates (dict): The default column delegates for the view. - hovered_item (ExchangesItem): The item currently being hovered over. - """ - defaultColumnDelegates = { - "amount": delegates.AbsoluteAmountDelegate, - "allocation_factor": delegates.FloatDelegate, - "substitution_factor": delegates.FloatDelegate, - "unit": delegates.StringDelegate, - "producer": RelinkDelegate, - "location": RelinkDelegate, - "product": RelinkDelegate, - "database": RelinkDelegate, - "categories": RelinkDelegate, - "formula": delegates.NewFormulaDelegate, - "comment": delegates.StringDelegate, - "uncertainty": delegates.UncertaintyDelegate, - } - - class HeaderMenu(widgets.ABMenu): - menuSetup = [ - lambda m: m.setup_view_menu(), - lambda m: m.setup_allocation(), - ] - - def setup_view_menu(self): - table_view: ExchangesView = self.parent() - table_model: ExchangesModel = table_view.model() - - def toggle_slot(action: QtWidgets.QAction): - """ - Toggles the visibility of columns based on the action triggered. - - Args: - action (QtWidgets.QAction): The action triggered. - """ - indices = action.data() - for index in indices: - hidden = table_view.isColumnHidden(index) - table_view.setColumnHidden(index, not hidden) - - # Create the view menu - view_menu = QtWidgets.QMenu(table_view) - view_menu.setTitle("View") - - props_indices = [] - - # Add actions for each column to the view menu - for i, col in enumerate(table_model.columns()): - if col.startswith("property"): - props_indices.append(i) - continue - - action = QtWidgets.QAction(table_model.columns()[i], self) - action.setCheckable(True) - action.setChecked(not table_view.isColumnHidden(i)) - action.setData([i]) - - view_menu.addAction(action) - - # Add a combined action for property columns - if props_indices: - action = QtWidgets.QAction("properties", self) - action.setCheckable(True) - action.setChecked(not table_view.isColumnHidden(props_indices[0])) - action.setData(props_indices) - view_menu.addAction(action) - - # Connect the view menu actions to the toggle slot - view_menu.triggered.connect(toggle_slot) - - # Add the view menu to the context menu - self.addMenu(view_menu) - - def setup_allocation(self): - table_view: ExchangesView = self.parent() - - if database_is_locked(table_view.activity["database"]) or not self.column.startswith("property"): - return - - action = app.actions.ActivityModify.get_QAction(table_view.activity.key, - "allocation", - self.column[9:], - parent=self) - action.setText(f"Allocate by {self.column[9:]}") - self.addAction(action) - - @property - def column(self): - view, model, pos = self.parent(), self.parent().model(), QtGui.QCursor.pos() - col_index = view.columnAt(view.mapFromGlobal(pos).x()) - return model.columns()[col_index] - - class ContextMenu(widgets.ABMenu): - menuSetup = [ - lambda m: m.add(app.actions.ActivityNewProduct, [m.activity.key], - enable=not m.locked and not database_is_legacy(m.activity["database"]) - ), - lambda m: m.add(app.actions.ActivityNewProduct, [m.activity.key], "waste", - enable=not m.locked and not database_is_legacy(m.activity["database"]), - text="Create waste" - ), - lambda m: m.addSeparator(), - lambda m: m.add(app.actions.ExchangeDelete, m.exchanges, enable=bool(m.exchanges) and not m.locked), - lambda m: m.add(app.actions.ExchangeSDFToClipboard, m.exchanges, enable=bool(m.exchanges)), - lambda m: m.add(app.actions.ActivityOpen, [x.input for x in m.exchanges], - enable=bool(m.exchanges), - text="Open processs" if len(m.exchanges) == 1 else "Open processes", - ), - ] - - @property - def locked(self): - return database_is_locked(self.activity["database"]) - - @property - def activity(self): - return self.parent().activity - - @property - def exchanges(self): - indexes = self.parent().selectedIndexes() - exchanges = [i.model().get(i, "_exchange") for i in indexes] - return list(set(exchanges)) - - def __init__(self, parent): - """ - Initializes the ExchangesView. - - Args: - parent (QtWidgets.QWidget): The parent widget. - """ - super().__init__(parent) - self.setSortingEnabled(True) - - # Enable drag and drop - self.setDragEnabled(True) - self.setAcceptDrops(True) - self.setDragDropMode(QtWidgets.QAbstractItemView.DragDropMode.DragDrop) - self.setDefaultDropAction(Qt.DropAction.MoveAction) - - self.drag_drop_hint = QtWidgets.QLabel("Drag products here to create new exchanges.", self) - fnt = self.drag_drop_hint.font() - fnt.setPointSize(fnt.pointSize() + 2) - fnt.setWeight(QtGui.QFont.Weight.ExtraLight) - self.drag_drop_hint.setFont(fnt) - - # Set up the layout - layout = QtWidgets.QVBoxLayout(self) - layout.addStretch() - layout.addWidget(self.drag_drop_hint, alignment=Qt.AlignmentFlag.AlignCenter) # Center horizontally - layout.addStretch() - - # Set the property delegate - self.propertyDelegate = delegates.PropertyDelegate(self) - self.overlay = widgets.ABDropOverlay(self) - self.overlay.hide() - - @property - def activity(self): - """ - Returns the activity associated with the view. - - Returns: - The activity associated with the view. - """ - return self.parent().parent().parent().activity - - def setDefaultColumnDelegates(self): - """ - Sets the default column delegates for the view. - """ - super().setDefaultColumnDelegates() - - columns = self.model().columns() - for i, col_name in enumerate(columns): - if not col_name.startswith("property_"): - continue - # Set the delegate for property columns - self.setItemDelegateForColumn(i, self.propertyDelegate) - - def startDrag(self, supportedActions: Qt.DropAction) -> None: - """ - Initiates a drag operation with the selected exchanges. - - Args: - supportedActions: The supported drop actions. - """ - if database_is_locked(self.activity["database"]): - return - - super().startDrag(supportedActions) - - -class ExchangesModel(core.ABTreeModel): - """ - A model representing the data for the exchanges. - """ - def __init__(self, tab: ExchangesTab): - super().__init__(parent=tab, enable_sorting=True) - self.tab = tab - - def mimeTypes(self) -> list[str]: - """ - Returns the list of MIME types that this model supports. - - Returns: - list[str]: List of supported MIME types. - """ - return ["application/bw-exchangelist"] - - def mimeData(self, indices: list[QtCore.QModelIndex]) -> core.ABMimeData: - """ - Returns the MIME data for the given indices. - - Args: - indices (list[QtCore.QModelIndex]): The indices to get the MIME data for. - - Returns: - core.ABMimeData: The MIME data containing the exchanges. - """ - data = core.ABMimeData() - exchanges = [self.get(index, "_exchange") for index in indices if index.isValid() and index.column() == 0] - exchanges = [exc for exc in exchanges if exc is not None] - data.setPickleData("application/bw-exchangelist", exchanges) - return data - - def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: - """ - Sets the data for the given index. - - Args: - index (QtCore.QModelIndex): The index to set data for. - value: The value to set. - role (int): The role for which to set the data. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if role != Qt.ItemDataRole.EditRole: - return False - - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return False - - exchange = row.get("_exchange") - if exchange is None: - return False - - if column_name in ["amount", "formula", "comment"]: - if column_name == "formula" and not str(value).strip(): - app.actions.ExchangeFormulaRemove.run([exchange]) - return True - - app.actions.ExchangeModify.run(exchange, {column_name.lower(): value}) - return True - - if column_name in ["unit", "product", "location", "substitution_factor", "allocation_factor"]: - act = exchange.input - app.actions.ActivityModify.run(act.key, column_name.lower(), value) - return True - - if column_name.startswith("property_"): - # should move this process to a separate action - process = exchange.output - product = exchange.input - - if not isinstance(process, bf.Process) or not isinstance(product, bf.Product): - logger.warning(f"Expected a Process and Product, got {type(process)} and {type(product)} instead.") - return False - - prop_key = column_name[9:] - - prop = process.property_template(prop_key, value) - - props = product.get("properties", {}) - props[prop_key] = prop - - app.actions.ActivityModify.run(product, "properties", props) - return True - - return False - - def decorationData(self, index: QtCore.QModelIndex) -> any: - """ - Provides decoration data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide decoration data. - - Returns: - The decoration data for the index. - """ - column_name = self.column_name(index) - - if column_name in ["product", "producer"]: - activity_type = self.get(index, "_producer_type") - if activity_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: - return icons.qicons.biosphere if column_name == "producer" else None - if activity_type == "processwithreferenceproduct": - return icons.qicons.processproduct if column_name == "producer" else icons.qicons.product - if activity_type in ["product", "process", "multifunctional", "nonfunctional"]: - return icons.qicons.process if column_name == "producer" else icons.qicons.product - if activity_type == "waste": - return icons.qicons.process if column_name == "producer" else icons.qicons.waste - - if column_name == "amount": - formula = self.get(index, "formula") - if pd.isna(formula) or formula is None or formula == "": - return None - return icons.qicons.parameterized - - return None - - def fontData(self, index: QtCore.QModelIndex) -> any: - """ - Provides font data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide font data. - - Returns: - QtGui.QFont: The font data for the index. - """ - if self.substituted(index): - font = QtGui.QFont() - font.setItalic(True) - font.setWeight(QtGui.QFont.Weight.DemiBold) - return font - - if self.functional(index): - font = QtGui.QFont() - font.setWeight(QtGui.QFont.Weight.DemiBold) - return font - - return None - - def indexEditable(self, index): - column_name = self.column_name(index) - database = self.get(index, "_exchange")["output"][0] - - # Prevent editing if the database is locked - if database_is_locked(database): - return False - - functional = self.functional(index) - - # Allow editing for specific keys: "amount", "formula", and "uncertainty". - if column_name in ["amount", "formula", "uncertainty", "comment"]: - return True - - # Allow editing for "unit", "name", and "substitution_factor" if the exchange is functional. - if column_name in ["unit", "product"] and functional: - return True - - # Allow editing for "producer", "location", "categories", and "database" if the exchange is not functional. - if column_name in ["producer", "product", "location", "categories", "database"] and not functional: - return True - - # Allow editing for properties (keys starting with "property_") if the exchange is functional. - if column_name.startswith("property_") and functional: - return True - - # Allow editing for allocation_factor if functional and allocation is manual - if column_name == "allocation_factor" and functional and self.tab.activity.get("allocation") == "manual": - return True - - return False - - def indexDragEnabled(self, index: QModelIndex) -> bool: - return True - - def functional(self, index): - """ - Returns whether the index is functional. - - Args: - index (QtCore.QModelIndex): The index to check. - - Returns: - bool: True if the index is functional, False otherwise. - """ - return self.get(index, "_exchange_type") == "production" - - def substituted(self, index): - """ - Returns whether the index is functional. - - Args: - index (QtCore.QModelIndex): The index to check. - - Returns: - bool: True if the index is functional, False otherwise. - """ - return self.get(index, "_exchange_type") == "substitution" - - def scoped_parameters(self, index): - """ - Returns the scoped parameters for the index. - - Args: - index (QtCore.QModelIndex): The index to get scoped parameters for. - - Returns: - list: A list of scoped parameters for the index. - """ - exchange = self.get(index, "_exchange") - return parameters_in_scope(exchange.output) - \ No newline at end of file diff --git a/activity_browser/app/pages/activity_details/graph_tab.py b/activity_browser/app/pages/activity_details/graph_tab.py deleted file mode 100644 index 8b44a7f86..000000000 --- a/activity_browser/app/pages/activity_details/graph_tab.py +++ /dev/null @@ -1,333 +0,0 @@ -import json -import os -from loguru import logger - -from qtpy import QtWebChannel, QtWebEngineWidgets, QtWidgets -from qtpy.QtCore import QObject, Qt, QUrl, Signal, SignalInstance, Slot - -import bw2data as bd -import bw_functional as bf - -from activity_browser import static, app -from activity_browser.bwutils.commontasks import refresh_node, database_is_locked -from activity_browser.ui import widgets -from .exchanges_tab import get_exchange_type - - - - -class GraphTab(QtWidgets.QWidget): - """ - A widget that displays a graph related to a specific activity. - - Attributes: - activity (tuple | int | bd.Node): The activity to display the graph for. - expanded_nodes (set): A set of node IDs that are expanded in the graph. - button (QtWidgets.QPushButton): A button to trigger synchronization. - bridge (Bridge): A bridge object for communication between Python and JavaScript. - backend (GraphBackend): A backend object for communication between Python and JavaScript. - url (QUrl): The URL of the HTML file to display. - channel (QtWebChannel.QWebChannel): A web channel for communication between Python and JavaScript. - page (Page): A web engine page to display the HTML content. - view (QtWebEngineWidgets.QWebEngineView): A web engine view to display the HTML content. - """ - def __init__(self, activity, parent=None): - """ - Initializes the GraphTab widget. - - Args: - activity (tuple | int | bd.Node): The activity to display the graph for. - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(parent) - self.setAcceptDrops(True) - - self.activity = refresh_node(activity) - self.expanded_nodes = {self.activity.id} - - self.button = QtWidgets.QPushButton("CLICK ME") - self.button.clicked.connect(self.sync) - - self.bridge = Bridge(self) - self.backend = GraphBackend(self) - self.url = QUrl.fromLocalFile(os.path.join(static.__path__[0], "activity_graph.html")) - - self.channel = QtWebChannel.QWebChannel(self) - self.channel.registerObject("bridge", self.bridge) - self.channel.registerObject("backend", self.backend) - - self.page = Page() - self.page.setWebChannel(self.channel) - - self.view = GraphView(self) - self.view.setPage(self.page) - self.view.setUrl(self.url) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.view) - self.setLayout(layout) - - self.bridge.ready.connect(self.sync) - - def sync(self): - """ - Synchronizes the widget with the current state of the activity. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - self.activity = refresh_node(self.activity) - json = self.build_json() - self.bridge.update_graph.emit(json) - - def build_json(self): - """ - Builds a JSON representation of the graph. - - Returns: - str: The JSON representation of the graph. - """ - nodes = [] - edges = [] - - collapsed_functions = set() - for node_id in self.expanded_nodes: - node = bd.get_node(id=node_id) - excs = list(node.exchanges()) - function_nodes = [exc.input for exc in excs if exc["type"] == "production"] - functions = [] - - for fn_node in function_nodes: - functions.append({ - "id": f"bw{fn_node.id}", - "name": fn_node._document.product if fn_node._document.product else fn_node["name"] - }) - excs.extend(fn_node.upstream()) - - nodes.append({ - "id": f"bw{node.id}", - "name": node["name"], - "functions": functions, - "type": "expanded_node" - }) - - for exc in excs: - if exc["type"] in ["production", "biosphere"]: - continue - processor = get_processor_from_exchange(exc) - - source_id = processor.id - target_id = exc.output.id - - if source_id not in self.expanded_nodes: - source_id = exc.input.id - collapsed_functions.add(source_id) - - if target_id not in self.expanded_nodes: - collapsed_functions.add(target_id) - - edges.append({ - "source_id": f"bw{source_id}", - "target_id": f"bw{exc.output.id}", - "function_id": f"bw{exc.input.id}", - }) - - for node_id in collapsed_functions: - fn_node = bd.get_node(id=node_id) - nodes.append({ - "id": f"bw{node_id}", - "name": fn_node._document.product if fn_node._document.product else fn_node["name"], - "functions": [], - "type": "collapsed_function" - }) - - full = { - "nodes": nodes, - "edges": edges, - } - - return json.dumps(full) - - def expand_node(self, node_id: str): - """ - Expands a node in the graph. - - Args: - node_id (str): The ID of the node to expand. - """ - node_id = int(node_id) # JS shenanigans can't deal with 64 bit strings - node = bd.get_node(id=node_id) - if isinstance(node, bf.Product): - node = bd.get_node(key=node["processor"]) - self.expanded_nodes.add(node.id) - self.sync() - - def collapse_node(self, node_id: str): - """ - Collapses a node in the graph. - - Args: - node_id (str): The ID of the node to collapse. - """ - node_id = int(node_id) # JS shenanigans can't deal with 64 bit strings - if self.activity.id == node_id: - return - self.expanded_nodes.remove(int(node_id)) - self.sync() - - -def get_processor_from_exchange(exchange): - """ - Gets the processor from an exchange. - - Args: - exchange: The exchange to get the processor from. - - Returns: - The processor of the exchange. - """ - source = exchange.input - processors = list(source.upstream(kinds=["production"])) - if len(processors) > 1: - logger.warning("Multiple processors, only taking first one") - processor = processors[0] - return processor.output - - -class GraphView(QtWebEngineWidgets.QWebEngineView): - - def __init__(self, parent=None): - super().__init__(parent) - self.setAcceptDrops(True) - self.setContextMenuPolicy(Qt.PreventContextMenu) - self.overlay = None - - def dragEnterEvent(self, event): - """ - Handles the drag enter event. - - Args: - event: The drag enter event. - """ - if database_is_locked(self.parent().activity["database"]): - return - - if event.mimeData().hasFormat("application/bw-nodekeylist"): - self.overlay = widgets.ABDropOverlay(self) - self.overlay.show() - event.accept() - - def dragLeaveEvent(self, event): - """ - Handles the drag leave event. - - Args: - event: The drag leave event. - """ - # Reset the palette on drag leave - self.overlay.deleteLater() - - def dropEvent(self, event): - """ - Handles the drop event. - - Args: - event: The drop event. - """ - logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") - # Reset the palette on drop - self.overlay.deleteLater() - - keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - exchanges = {"technosphere": set(), "biosphere": set()} - - for key in keys: - if exc_type := get_exchange_type(key): - exchanges[exc_type].add(key) - - # Run the action for new exchanges - for exc_type, keys in exchanges.items(): - app.actions.ExchangeNew.run(keys, self.parent().activity.key, exc_type) - - -class GraphBackend(QObject): - """ - A backend object for communication between Python and JavaScript. - This object is exposed to the JavaScript side and provides methods - that can be called from JavaScript to control the graph. - """ - def __init__(self, graph_tab: GraphTab, parent=None): - """ - Initializes the GraphBackend object. - - Args: - graph_tab (GraphTab): The GraphTab widget this backend is associated with. - parent (QObject, optional): The parent object. Defaults to None. - """ - super().__init__(parent) - self.graph_tab = graph_tab - - @Slot(str) - def expand_node(self, node_id: str): - """ - Expands a node in the graph. - - Args: - node_id (str): The ID of the node to expand. - """ - self.graph_tab.expand_node(node_id) - - @Slot(str) - def collapse_node(self, node_id: str): - """ - Collapses a node in the graph. - - Args: - node_id (str): The ID of the node to collapse. - """ - self.graph_tab.collapse_node(node_id) - - -class Bridge(QObject): - """ - A bridge for communication between Python and JavaScript. - - Attributes: - update_graph (SignalInstance): A signal to update the graph. - ready (SignalInstance): A signal indicating that the bridge is ready. - """ - update_graph: SignalInstance = Signal(str) - ready: SignalInstance = Signal() - - @Slot() - def is_ready(self): - """ - Emits the ready signal. - """ - self.ready.emit() - - -class Page(QtWebEngineWidgets.QWebEnginePage): - """ - A web engine page to display the HTML content. - - Methods: - javaScriptConsoleMessage: Logs JavaScript console messages. - """ - def javaScriptConsoleMessage(self, level: QtWebEngineWidgets.QWebEnginePage.JavaScriptConsoleMessageLevel, message: str, line: str, _: str): - """ - Logs JavaScript console messages. - - Args: - level (QtWebEngineWidgets.QWebEnginePage.JavaScriptConsoleMessageLevel): The message level. - message (str): The message content. - line (str): The line number. - _ (str): Unused parameter. - """ - if level == QtWebEngineWidgets.QWebEnginePage.InfoMessageLevel: - logger.info(f"JS Info (Line {line}): {message}") - elif level == QtWebEngineWidgets.QWebEnginePage.WarningMessageLevel: - logger.warning(f"JS Warning (Line {line}): {message}") - elif level == QtWebEngineWidgets.QWebEnginePage.ErrorMessageLevel: - logger.error(f"JS Error (Line {line}): {message}") - else: - logger.debug(f"JS Log (Line {line}): {message}") diff --git a/activity_browser/app/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py deleted file mode 100644 index dd055fbb3..000000000 --- a/activity_browser/app/pages/activity_details/parameters_tab.py +++ /dev/null @@ -1,387 +0,0 @@ -from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt -from loguru import logger - -import pandas as pd -import bw2data as bd - -from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, Group, ParameterBase - -from activity_browser import app -from activity_browser.ui import widgets, icons, delegates, core -from activity_browser.bwutils.commontasks import refresh_node, refresh_parameter, parameters_in_scope, database_is_locked, node_group -from activity_browser.bwutils.utils import Parameter - - -class ParametersTab(QtWidgets.QWidget): - """ - A widget that displays parameters related to a specific activity. - - Attributes: - activity (tuple | int | bd.Node): The activity to display parameters for. - model (ParametersModel): The model containing the data for the parameters. - view (ParametersView): The view displaying the parameters. - """ - def __init__(self, activity, parent=None): - """ - Initializes the ParametersTab widget. - - Args: - activity (tuple | int | bd.Node): The activity to display parameters for. - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(parent) - self.activity = refresh_node(activity) - - self.view = ParametersView(self) - self.view.setSortingEnabled(False) - self.view.setUniformRowHeights(True) - - self.model = ParametersModel(tab=self) - self.view.setModel(self.model) - - self.build_layout() - self.connect_signals() - - def build_layout(self): - """ - Builds the layout of the widget. - """ - layout = QtWidgets.QVBoxLayout() - layout.setContentsMargins(0, 10, 0, 1) - layout.addWidget(self.view) - - self.setLayout(layout) - - def connect_signals(self): - """ - Connects signals to their respective slots. - """ - app.signals.parameter.changed.connect(self.sync) - app.signals.parameter.recalculated.connect(self.sync) - app.signals.parameter.deleted.connect(self.sync) - - def sync(self): - """ - Synchronizes the widget with the current state of the activity. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - df = self.build_df() - self.model.set_dataframe(df, group=["_param_type", "_scope"]) - self.view.expandAll() - - self.view.resizeColumnToContents(1) - self.view.resizeColumnToContents(3) - self.view.resizeColumnToContents(4) - - def build_df(self) -> pd.DataFrame: - """ - Builds a DataFrame from all parameters in the project. - - Returns: - pd.DataFrame: The DataFrame containing the parameters data. - """ - translated = [] - - # Project parameters - for param in ProjectParameter.select(): - row = self._parameter_to_row(param) - translated.append(row) - - translated.append({ - "name": "New parameter...", - "_group": "project", - "_param_type": "project", - "_class": "new", - }) - - # Database parameters - db_params = DatabaseParameter.select() - db_name = self.activity["database"] - - for param in db_params.where(DatabaseParameter.database == db_name): - row = self._parameter_to_row(param, db_name, db_name) - translated.append(row) - - if not database_is_locked(db_name): - translated.append({ - "name": "New parameter...", - "_scope": db_name, - "_database": db_name, - "_group": db_name, - "_param_type": "database", - "_class": "new", - }) - - # Activity parameters - act_params = ActivityParameter.select() - group_name = node_group(self.activity) or str(self.activity.id) - - for param in act_params.where(ActivityParameter.group == group_name): - row = self._parameter_to_row(param, f"Group: {group_name}", param.database) - translated.append(row) - - if not database_is_locked(self.activity["database"]): - translated.append({ - "name": "New parameter...", - "_scope": f"Group: {group_name}", - "_database": self.activity["database"], - "_group": group_name, - "_param_type": "activity", - "_class": "new", - }) - - columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_database", "_group", - "_param_type", "_class"] - df = pd.DataFrame(translated, columns=columns) - - df["_activity"] = [self.activity for i in range(len(df))] - return df - - def _parameter_to_row(self, param, scope_label: str = None, database: str = None) -> dict: - """ - Converts a parameter to a row dictionary. - - Args: - param: The parameter to convert (ProjectParameter, DatabaseParameter, or ActivityParameter). - scope_label: The label for the scope (e.g., "Current project", "Database: ecoinvent"). - database: The database name (None for project parameters). - - Returns: - dict: A dictionary representing the parameter row. - """ - data = param.dict - - # Create Parameter wrapper - if isinstance(param, ProjectParameter): - parameter = Parameter(param.name, "project", data.get("amount"), data, "project") - group = "project" - param_type = "project" - elif isinstance(param, DatabaseParameter): - parameter = Parameter(param.name, param.database, data.get("amount"), data, "database") - group = param.database - param_type = "database" - elif isinstance(param, ActivityParameter): - parameter = Parameter(param.name, param.group, data.get("amount"), data, "activity") - group = param.group - param_type = "activity" - else: - raise ValueError(f"Unknown parameter type: {type(param)}") - - row = { - "name": parameter.name, - "amount": parameter.amount, - "uncertainty": parameter.uncertainty, - "formula": data.get("formula"), - "comment": data.get("comment"), - "_param_type": param_type, - "_parameter": parameter, - "_scope": scope_label, - "_database": database, - "_group": group, - "_class": "instantiated", - } - - return row - - -class ParametersView(widgets.ABTreeView): - """ - A view that displays the parameters in a tree structure. - - Attributes: - defaultColumnDelegates (dict): The default column delegates for the view. - """ - defaultColumnDelegates = { - "amount": delegates.FloatDelegate, - "name": delegates.StringDelegate, - "formula": delegates.NewFormulaDelegate, - "comment": delegates.StringDelegate, - "uncertainty": delegates.UncertaintyDelegate, - } - - class ContextMenu(widgets.ABMenu): - menuSetup = [ - lambda m: m.add(app.actions.ParameterDelete, m.parameters, enable=bool(m.parameters) and not m.locked), - ] - - @property - def locked(self): - table_view: ParametersView = self.parent() - return database_is_locked(table_view.activity["database"]) - - @property - def activity(self): - table_view: ParametersView = self.parent() - return table_view.activity - - @property - def parameters(self): - table_view: ParametersView = self.parent() - table_model: ParametersModel = table_view.model() - - selected_indices = table_view.selectedIndexes() - params = table_model.values_from_indices("_parameter", selected_indices) - # Convert to peewee models - return [p.to_peewee_model() for p in params if p is not None] - - @property - def activity(self): - """ - Returns the activity associated with the view. - - Returns: - The activity associated with the view. - """ - return self.parent().activity - - -class ParametersModel(core.ABTreeModel): - """ - A model representing the data for the parameters. - """ - def __init__(self, tab: ParametersTab): - super().__init__(parent=tab) - self.tab = tab - - def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: - """ - Sets the data for the given index. - - Args: - index (QtCore.QModelIndex): The index to set data for. - value: The value to set. - role (int): The role for which to set the data. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if role != Qt.ItemDataRole.EditRole: - return False - - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return False - - # Handle "New parameter..." rows - if row.get("_class") == "new": - if column_name != "name" or value == "": - return False - - parameter = Parameter( - name=value, - group=row.get("_group"), - param_type=row.get("_param_type") - ) - - app.actions.ParameterNewFromParameter.run(parameter) - return True - - # Handle regular parameter edits - parameter = row.get("_parameter") - if parameter is None: - return False - - if column_name in ["amount", "formula", "name", "comment"]: - parameter = refresh_parameter(parameter) - app.actions.ParameterModify.run(parameter, column_name, value) - - if column_name == "uncertainty": - parameter = refresh_parameter(parameter) - app.actions.ParameterUncertaintyModify.run(parameter.to_peewee_model(), uncertainty_dict=value) - - return True - - return False - - def decorationData(self, index: QtCore.QModelIndex) -> any: - """ - Provides decoration data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide decoration data. - - Returns: - The decoration data for the index. - """ - column_name = self.column_name(index) - - if column_name == "amount": - formula = self.get(index, "formula") - formula = isinstance(formula, str) and formula.strip() - - return icons.qicons.parameterized if formula else icons.qicons.empty - - return None - - def fontData(self, index: QtCore.QModelIndex) -> any: - """ - Provides font data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide font data. - - Returns: - QtGui.QFont: The font data for the index. - """ - param_class = self.get(index, "_class") - if param_class == "new": - font = QtGui.QFont() - font.setWeight(QtGui.QFont.Weight.ExtraLight) - return font - - if param_class == "broken": - font = QtGui.QFont() - font.setWeight(QtGui.QFont.Weight.Bold) - return font - - return None - - def indexEditable(self, index: QtCore.QModelIndex) -> bool: - """ - Returns whether the index is editable. - - Args: - index (QtCore.QModelIndex): The index to check. - - Returns: - bool: True if the index is editable, False otherwise. - """ - column_name = self.column_name(index) - - # Check if database is locked - database = self.get(index, "_database") - if not pd.isna(database) and database_is_locked(database): - return False - - # Prevent editing broken parameters - if self.get(index, "_class") == "broken": - return False - - # Allow editing for specific columns - if column_name in ["formula", "uncertainty", "name", "comment"]: - return True - - if column_name == "amount" and not self.get(index, "formula"): - return True - - return False - - def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: - """ - Returns the parameters in scope of the parameter at the given index. - - Args: - index (QtCore.QModelIndex): The index to get scoped parameters for. - - Returns: - dict: The parameters in scope. - """ - parameter = self.get(index, "_parameter") - if parameter is None or isinstance(parameter, float): # NaN check - return {} - - return parameters_in_scope(parameter=parameter) diff --git a/activity_browser/app/pages/calculation_setup/__init__.py b/activity_browser/app/pages/calculation_setup/__init__.py deleted file mode 100644 index eb8edb84e..000000000 --- a/activity_browser/app/pages/calculation_setup/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .calculation_setup import CalculationSetupPage \ No newline at end of file diff --git a/activity_browser/app/pages/calculation_setup/calculation_setup.py b/activity_browser/app/pages/calculation_setup/calculation_setup.py deleted file mode 100644 index 17e2dc0f6..000000000 --- a/activity_browser/app/pages/calculation_setup/calculation_setup.py +++ /dev/null @@ -1,93 +0,0 @@ -from qtpy import QtWidgets -from loguru import logger - -from activity_browser import app -from activity_browser.ui import widgets, icons - -from .scenario_section import ScenarioSection -from .functional_unit_section import FunctionalUnitSection -from .impact_category_section import ImpactCategorySection - - -class CalculationSetupPage(QtWidgets.QWidget): - - def __init__(self, cs_name: str, parent=None): - super().__init__(parent) - self.setObjectName(cs_name) - - self.calculation_setup_name = cs_name - - self.type_dropdown = QtWidgets.QComboBox() - self.type_dropdown.addItems(["Standard", "Scenario"]) - - self.run_button = QtWidgets.QPushButton("Run", self) - self.run_button.setIcon(icons.qicons.forward) - self.run_button.setStyleSheet("background-color: #57965C;") - - self.functional_unit_section = FunctionalUnitSection(cs_name, self) - self.impact_category_section = ImpactCategorySection(cs_name, self) - self.scenario_section = ScenarioSection(self) - self.scenario_section.hide() - - # Build the layout of the widget - self.build_layout() - self.sync() - self.connect_signals() - - def build_layout(self): - """ - Builds the layout of the widget. - """ - layout = QtWidgets.QVBoxLayout() - layout.setContentsMargins(0, 3, 0, 0) - - top_layout = QtWidgets.QHBoxLayout() - top_layout.setContentsMargins(0, 0, 10, 0) - top_layout.addWidget(widgets.ABLabel.demiBold(" Functional Units:", self)) - top_layout.addStretch() - top_layout.addWidget(self.type_dropdown) - top_layout.addWidget(self.run_button) - - # Add fu label and view to the layout - layout.addLayout(top_layout) - layout.addWidget(self.functional_unit_section) - - # Add ic label and view to the layout - layout.addWidget(widgets.ABLabel.demiBold(" Impact Categories:", self)) - layout.addWidget(self.impact_category_section) - - # Add scenario label and view to the layout - - layout.addWidget(self.scenario_section) - - # Set the layout for the widget - self.setLayout(layout) - - def connect_signals(self): - app.signals.project.changed.connect(self.sync) - app.signals.meta.calculation_setups_changed.connect(self.sync) - - self.type_dropdown.currentTextChanged.connect(self.type_switch) - self.run_button.released.connect(self.run_calculation) - - def sync(self) -> None: - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - self.functional_unit_section.sync() - self.impact_category_section.sync() - - def type_switch(self, calculation_type: str): - if calculation_type == "Standard": - self.scenario_section.hide() - elif calculation_type == "Scenario": - self.scenario_section.show() - else: - raise ValueError(f"Unknown calculation type: {calculation_type}") - - def run_calculation(self): - if self.type_dropdown.currentText() == "Standard": - app.actions.CSCalculate.run(self.calculation_setup_name) - elif self.type_dropdown.currentText() == "Scenario": - scenario_data = self.scenario_section.scenario_dataframe() - app.actions.CSCalculate.run(self.calculation_setup_name, scenario_data) - diff --git a/activity_browser/app/pages/calculation_setup/functional_unit_section.py b/activity_browser/app/pages/calculation_setup/functional_unit_section.py deleted file mode 100644 index baf8be3cb..000000000 --- a/activity_browser/app/pages/calculation_setup/functional_unit_section.py +++ /dev/null @@ -1,241 +0,0 @@ -from qtpy import QtWidgets, QtCore -from qtpy.QtCore import Qt -from loguru import logger - -import bw2data as bd -import pandas as pd - -from activity_browser import app -from activity_browser.ui import widgets, icons, delegates, core -from activity_browser.bwutils.commontasks import is_node_product_or_waste - - -class FunctionalUnitSection(QtWidgets.QWidget): - def __init__(self, calculation_setup_name: str, parent=None): - super().__init__(parent) - - self.calculation_setup_name = calculation_setup_name - self.calculation_setup = bd.calculation_setups.get(self.calculation_setup_name) - - self.view = FunctionalUnitView(self) - self.model = FunctionalUnitModel(parent=self) - self.view.setModel(self.model) - - self.build_layout() - - def build_layout(self): - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.view) - self.setLayout(layout) - - def sync(self): - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - try: - self.calculation_setup = bd.calculation_setups[self.calculation_setup_name] - df = self.build_df() - df.reset_index(drop=True, inplace=True) - self.model.set_dataframe(df) - except KeyError: - self.parent().close() - self.parent().deleteLater() - - def build_df(self): - keys, amounts = [], [] - cols = ["unit", "name", "product", "location", "database", "processor", "type"] - - for fu in self.calculation_setup.get("inv", []): - for key, amount in fu.items(): - keys.append(key) - amounts.append(amount) - - act_df = app.metadata.get_metadata(keys, cols) - act_df["amount"] = amounts - act_df["_activity_key"] = keys - act_df["_cs_name"] = self.calculation_setup_name - - act_df["_processor_key"] = act_df["processor"] - act_df["_processor_key"] = act_df["_processor_key"].fillna(act_df["_activity_key"]) - - # Retrieve metadata for unique processor keys, focusing on the "name" column. - processor_df = app.metadata.get_metadata(act_df["_processor_key"].unique(), ["name"]) - - # Flatten the index of the processor DataFrame to ensure compatibility with merging. - processor_df.index = processor_df.index.to_flat_index() - - # Merge the processor keys from the activity DataFrame with the processor metadata. - processor_df = pd.merge(act_df["_processor_key"].astype(object), processor_df, "right", - left_on="_processor_key", right_index=True, ) - - # Add a column for function keys by flattening the index of the processor DataFrame. - processor_df["function_keys"] = processor_df.index.to_flat_index() - - # Remove duplicate rows from the processor DataFrame to ensure uniqueness. - processor_df = processor_df.drop_duplicates() - - # Add the "process" column to the activity DataFrame using the processor names. - act_df["process"] = processor_df["name"] - - # Use "product" if available otherwise use "name" - act_df.update(act_df["product"].rename("name")) - act_df["product"] = act_df["name"] - - act_df.rename({"type": "_type"}, axis="columns", inplace=True) - - cols = ["amount", "unit", "product", "process", "database", "location", "_processor_key", "_activity_key", "_cs_name", "_type"] - - return act_df[cols].reset_index(drop=True) - - -class FunctionalUnitView(widgets.ABTreeView): - defaultColumnDelegates = { - "amount": delegates.AmountDelegate - } - - class ContextMenu(widgets.ABMenu): - menuSetup = [ - lambda m, p: m.add(app.actions.ActivityOpen, p.selected_processes(), - text="Open process" if len(p.selected_processes()) == 1 else "Open processes", - enable=len(p.selected_processes()) > 0 - ), - lambda m: m.addSeparator(), - lambda m, p: m.add(app.actions.CSDeleteFunctionalUnit, p.cs_name(), p.selected_row_indices(), - text="Delete Functional Unit" if len(p.selected_processes()) == 1 else "Delete Functional Units", - enable=len(p.selected_processes()) > 0 - ), - - ] - - def __init__(self, parent=None): - super().__init__(parent) - self.setAcceptDrops(True) - self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) - self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - - def mouseDoubleClickEvent(self, event) -> None: - """ - Handles the mouse double click event to open the selected activities. - - Args: - event: The mouse double click event. - """ - index = self.indexAt(event.pos()) - if index.column() == 1: # Prevent action on amount column - return super().mouseDoubleClickEvent(event) - - if self.selectedIndexes(): - activities = self.model().values_from_indices("_processor_key", self.selectedIndexes()) - app.actions.ActivityOpen.run(list(set(activities))) - - return None - - def dragMoveEvent(self, event) -> None: - pass - - def dragEnterEvent(self, event): - if event.mimeData().hasFormat("application/bw-nodekeylist"): - keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - for key in keys: - if not is_node_product_or_waste(key): - keys.remove(key) - - if not keys: - return - - event.accept() - - def dropEvent(self, event) -> None: - event.accept() - cs_name = self.parent().calculation_setup_name - - keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - for key in keys.copy(): - if not is_node_product_or_waste(key): - keys.remove(key) - - app.actions.CSAddFunctionalUnit.run(cs_name, keys) - - def selected_row_indices(self): - return [i.row() for i in super().selectedIndexes()] - - def cs_name(self): - return self.parent().calculation_setup_name - - def selected_processes(self): - return list(set(self.model().values_from_indices("_processor_key", self.selectedIndexes()))) - - -class FunctionalUnitModel(core.ABTreeModel): - """ - A model representing the data for the functional units. - """ - - def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: - """ - Sets the data for the given index. - - Args: - index (QtCore.QModelIndex): The index to set data for. - value: The value to set. - role (int): The role for which to set the data. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if role != Qt.ItemDataRole.EditRole: - return False - - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return False - - if column_name == "amount": - cs_name = row.get("_cs_name") - app.actions.CSChangeFunctionalUnit.run(cs_name, index.row(), value) - return True - - return False - - def decorationData(self, index: QtCore.QModelIndex) -> any: - """ - Provides decoration data (icons) for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide decoration data. - - Returns: - The decoration data (icon) for the index. - """ - column_name = self.column_name(index) - - if column_name == "product": - product_type = self.get(index, "_type") - if product_type == "waste": - return icons.qicons.waste - elif product_type == "processwithreferenceproduct": - return icons.qicons.processproduct - else: - return icons.qicons.product - elif column_name == "process": - return icons.qicons.process - - return None - - def indexEditable(self, index: QtCore.QModelIndex) -> bool: - """ - Returns whether the index is editable. - - Args: - index (QtCore.QModelIndex): The index to check. - - Returns: - bool: True if the index is editable, False otherwise. - """ - column_name = self.column_name(index) - - if column_name == "amount": - return True - - return False diff --git a/activity_browser/app/pages/calculation_setup/impact_category_section.py b/activity_browser/app/pages/calculation_setup/impact_category_section.py deleted file mode 100644 index 2fcda22f8..000000000 --- a/activity_browser/app/pages/calculation_setup/impact_category_section.py +++ /dev/null @@ -1,98 +0,0 @@ -from qtpy import QtWidgets -from loguru import logger - -import bw2data as bd -import pandas as pd - -from activity_browser import app -from activity_browser.ui import widgets, delegates, core - - -class ImpactCategorySection(QtWidgets.QWidget): - def __init__(self, calculation_setup_name: str, parent=None): - super().__init__(parent) - - self.calculation_setup_name = calculation_setup_name - self.calculation_setup = bd.calculation_setups.get(self.calculation_setup_name) - - self.view = ImpactCategoryView(self) - self.model = ImpactCategoryModel(parent=self) - self.view.setModel(self.model) - - self.build_layout() - - def build_layout(self): - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.view) - self.setLayout(layout) - - def sync(self): - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - try: - self.calculation_setup = bd.calculation_setups[self.calculation_setup_name] - df = self.build_df() - df.reset_index(drop=True, inplace=True) - self.model.set_dataframe(df) - except KeyError: - self.parent().close() - self.parent().deleteLater() - - def build_df(self): - data = [bd.methods.get(method_name) for method_name in self.calculation_setup.get("ia", [])] - df = pd.DataFrame(data, columns=["name", "unit", "num_cfs"]) - - df["name"] = self.calculation_setup.get("ia", []) - df["_cs_name"] = self.calculation_setup_name - - cols = ["name", "unit", "num_cfs", "_cs_name"] - - return df[cols] - - -class ImpactCategoryView(widgets.ABTreeView): - defaultColumnDelegates = { - "name": delegates.StringDelegate - } - - class ContextMenu(widgets.ABMenu): - menuSetup = [ - lambda m, p: m.add(app.actions.CSDeleteImpactCategory, m.cs_name, m.selected_ics, - text="Delete Impact Category" if len(m.selected_ics) == 1 else "Delete Impact Categories", - enable=len(m.selected_ics) > 0 - ), - ] - - @property - def selected_ics(self): - return list(set([index.row() for index in self.parent().selectedIndexes()])) - - @property - def cs_name(self): - return self.parent().parent().calculation_setup_name - - def __init__(self, parent=None): - super().__init__(parent) - self.setAcceptDrops(True) - self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) - self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - - def dragMoveEvent(self, event) -> None: - pass - - def dragEnterEvent(self, event): - if event.mimeData().hasFormat("application/bw-methodnamelist"): - event.accept() - - def dropEvent(self, event) -> None: - event.accept() - cs_name = self.parent().calculation_setup_name - method_names = event.mimeData().retrievePickleData("application/bw-methodnamelist") - app.actions.CSAddImpactCategory.run(cs_name, method_names) - - -class ImpactCategoryModel(core.ABTreeModel): - """ - A model representing the data for the impact categories. - """ - pass diff --git a/activity_browser/app/pages/calculation_setup/scenario_section.py b/activity_browser/app/pages/calculation_setup/scenario_section.py deleted file mode 100644 index ff7040fef..000000000 --- a/activity_browser/app/pages/calculation_setup/scenario_section.py +++ /dev/null @@ -1,580 +0,0 @@ -from loguru import logger -from pathlib import Path - -from qtpy import QtWidgets -from qtpy.QtCore import Qt - -import pandas as pd -import bw2data as bd -from activity_browser.bwutils import superstructure as ss - -from activity_browser import app -from activity_browser.ui import icons, widgets - - - -class ScenarioSection(QtWidgets.QWidget): - max_tables = 5 - - """Special kind of QWidget that contains one or more tables side by side.""" - - def __init__(self, parent=None): - super().__init__(parent) - - self.tables = [] - self._scenario_dataframe = pd.DataFrame() - - # set up the control buttons - self.get_template_btn = app.actions.SaveParametersToExcel.get_QButton() - self.get_template_btn.setText("Parameter template...") - - self.table_btn = QtWidgets.QPushButton("Add scenarios...", self) - - self.save_scenario = QtWidgets.QPushButton("Save to file...", self) - self.save_scenario.setDisabled(True) - - # set up the combination buttons - - # initiate the combine scenarios button - self.product_choice = QtWidgets.QRadioButton("Combine scenarios", self) - self.product_choice.setChecked(True) - - # initiate the extend scenarios button - self.addition_choice = QtWidgets.QRadioButton("Extend scenarios", self) - - # group them and make them exclusive - self.combine_group = QtWidgets.QButtonGroup(self) - self.combine_group.setExclusive(True) - self.combine_group.addButton(self.product_choice) - self.combine_group.addButton(self.addition_choice) - - # orient them horizontally - input_field_layout = QtWidgets.QHBoxLayout() - input_field_layout.setContentsMargins(0, 0, 0, 0) - input_field_layout.addWidget(self.product_choice) - input_field_layout.addWidget(self.addition_choice) - - # add the border and hide until further notice - self.group_box = QtWidgets.QGroupBox() - self.group_box.setLayout(input_field_layout) - self.group_box.setDisabled(True) - - # combining all into the tool row - tool_row = QtWidgets.QHBoxLayout() - tool_row.setContentsMargins(0, 0, 0, 0) - tool_row.addSpacing(10) - - tool_row.addWidget(widgets.ABLabel.demiBold(" Scenarios:", self)) - tool_row.addStretch() - tool_row.addWidget(self.get_template_btn) - tool_row.addWidget(self.table_btn) - tool_row.addWidget(self.save_scenario) - tool_row.addWidget(self.group_box) - - # layout for the different scenario tables that can be added - self.scenario_tables = QtWidgets.QHBoxLayout() - - # statistics at the bottom of the widget - self.stats_widget = QtWidgets.QLabel() - self.update_stats() - - # construct the full layout - layout = QtWidgets.QVBoxLayout() - layout.setContentsMargins(0, 0, 10, 0) - layout.addLayout(tool_row) - layout.addLayout(self.scenario_tables) - layout.addStretch(1) - layout.addWidget(self.stats_widget) - self.setLayout(layout) - - self.connect_signals() - - def connect_signals(self) -> None: - app.signals.project.changed.connect(self.clear_tables) - app.signals.project.changed.connect(self.can_add_table) - - self.table_btn.clicked.connect(self.add_table) - self.table_btn.clicked.connect(self.can_add_table) - self.save_scenario.clicked.connect(self.save_action) - self.combine_group.buttonClicked.connect(self.toggle_combine_type) - - def update_stats(self) -> None: - """Update the statistics at the bottom of the widget""" - n_scenarios = len(self._scenario_dataframe.columns) - n_flows = len(self._scenario_dataframe) - - stats = f"Total number of scenarios: {n_scenarios} | Total number of variable flows: {n_flows}" - self.stats_widget.setText(stats) - - def toggle_combine_type(self) -> None: - """Called by signal when the combine type is switched by the user""" - try: - # try to update the combined dataframe - self.combined_dataframe() - except: - # revert when an exception occurs - type = self.get_combine_type() - if type == "product": - self.addition_choice.setChecked(True) - if type == "addition": - self.product_choice.setChecked(True) - - def get_combine_type(self) -> str: - """Return the type of combination the user wants to do""" - if self.product_choice.isChecked(): - return "product" - elif self.addition_choice.isChecked(): - return "addition" - - def scenario_dataframe(self) -> pd.DataFrame: - return self._scenario_dataframe - - def scenario_names(self, idx: int) -> list: - if idx > len(self.tables): - return [] - return ss.scenario_names_from_df(self.tables[idx]) - - def combined_dataframe(self, skip_checks: bool = False) -> None: - """Updates scenario dataframe to contain the combined scenarios of multiple tables.""" - # if there are no tables currently, set the dataframe to be empty - if not self.tables: - self._scenario_dataframe = pd.DataFrame() - self.update_stats() - return - - # if the tables are empty, set the dataframe to be empty - data = [df for df in (t.dataframe for t in self.tables) if not df.empty] - if not data: - self._scenario_dataframe = pd.DataFrame() - self.update_stats() - return - - # check what kind of combination the user wants to do - kind = self.get_combine_type() - - # combine the data using SuperstructureManager and update the dataframe - manager = ss.SuperstructureManager(*data) - self._scenario_dataframe = manager.combined_data(kind, skip_checks) - - # update the stats at the bottom of the widget - self.update_stats() - - def add_table(self) -> None: - """Add a new table widget to the widget and add to the list of tables""" - new_idx = len(self.tables) - widget = ScenarioImportWidget(new_idx, self) - self.tables.append(widget) - self.scenario_tables.addWidget(widget) - self.updateGeometry() - - def remove_table(self, index: int) -> None: - """Remove the table widget at the provided index""" - # remove from the self.tables list and the layout - table_widget = self.tables.pop(index) - self.scenario_tables.removeWidget(table_widget) - - # update the other widgets with new indices - for i, widget in enumerate(self.tables): - widget.index = i - - # if there was data in the widget, recalculate the combined DF - if not table_widget.dataframe.empty: - self.combined_dataframe(skip_checks=True) - - # free up the memory - table_widget.deleteLater() - - # update save_scenario button - if not self.tables: - self.save_scenario.setDisabled(True) - self.updateGeometry() - - def clear_tables(self) -> None: - """Clear all scenario tables in certain cases (eg. project change).""" - for w in self.tables: - self.scenario_tables.removeWidget(w) - w.deleteLater() - self.tables = [] - self.save_scenario.setDisabled(True) - self.updateGeometry() - self.combined_dataframe() - - def updateGeometry(self): - self.group_box.setDisabled(len(self.tables) <= 1) - # Make sure that scenario tables are equally balanced within the box. - if self.tables: - table_width = self.width() / len(self.tables) - for table in self.tables: - table.setMaximumWidth(table_width) - super().updateGeometry() - - def can_add_table(self) -> None: - """Use this to set a hardcoded limit on the amount of scenario tables - a user can add. - """ - self.table_btn.setEnabled(len(self.tables) < self.max_tables) - - def save_action(self) -> None: - """Creates and saves to file (.xlsx, or .csv) the scenario dataframe after the loaded scenarios have been - merged. Will not contain duplicates. Will not contain self-referential technosphere flows. - - Triggered by a signal from ScenarioImportPanel save button, uses a dummy input argument. - """ - filepath, _ = QtWidgets.QFileDialog.getSaveFileName( - parent=self, - caption="Choose location to save the scenario file", - filter="Excel (*.xlsx *.xls);; CSV (*.csv)", - ) - print("Saving scenario dataframe to file: ", filepath) - scenarios = self._scenario_dataframe.columns.difference( - ["input", "output", "flow"] - ) - superstructure = ss.SUPERSTRUCTURE.tolist() - cols = superstructure + scenarios.tolist() - - savedf = pd.DataFrame(index=self._scenario_dataframe.index, columns=cols) - for table in self.tables: - indices = savedf.index.intersection(table.scenario_df.index) - savedf.loc[indices, superstructure] = table.scenario_df.loc[ - indices, superstructure - ] - savedf.loc[indices, scenarios] = self._scenario_dataframe.loc[ - indices, scenarios - ] - if filepath.endswith(".xlsx") or filepath.endswith(".xls"): - savedf.to_excel(filepath, index=False) - return - elif not filepath.endswith(".csv"): - filepath += ".csv" - savedf.to_csv(filepath, index=False, sep=";") - - def save_button(self, visible: bool): - self.save_scenario.setDisabled(not visible) - self.show() - self.updateGeometry() - - -class ScenarioImportWidget(QtWidgets.QWidget): - def __init__(self, index: int, parent=None): - super().__init__(parent) - self._parent = parent - self.index = index - self.scenario_name = QtWidgets.QLabel("", self) - self.load_btn = QtWidgets.QPushButton(icons.qicons.import_db, "Load") - self.load_btn.setToolTip("Load (new) data for this scenario table") - self.remove_btn = QtWidgets.QPushButton(icons.qicons.delete, "Delete") - self.remove_btn.setToolTip("Remove this scenario table") - self.view = widgets.ABTreeView(self) - self.model = widgets.ABItemModel(self) - self.view.setModel(self.model) - self.scenario_df = pd.DataFrame(columns=ss.SUPERSTRUCTURE) - - layout = QtWidgets.QVBoxLayout() - - row = QtWidgets.QHBoxLayout() - row.addWidget(self.scenario_name) - row.addWidget(self.load_btn) - row.addStretch(1) - row.addWidget(self.remove_btn) - - layout.addLayout(row) - layout.addWidget(self.view) - layout.addStretch(1) - self.setLayout(layout) - self.connect_signals() - - def connect_signals(self): - self.load_btn.clicked.connect(self.load_action) - parent = self.parent() - if parent and isinstance(parent, ScenarioSection): - self.remove_btn.clicked.connect(lambda: parent.remove_table(self.index)) - self.remove_btn.clicked.connect(parent.can_add_table) - - def load_action(self) -> None: - dialog = ExcelReadDialog(self) - if dialog.exec_() != ExcelReadDialog.DialogCode.Accepted: - return - - try: - path = dialog.path - idx = dialog.import_sheet.currentIndex() - file_type_suffix = dialog.path.suffix - separator = dialog.field_separator.currentData() - logger.debug("separator == '{}'".format(separator)) - QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - logger.info("Loading Scenario file. This may take a while for large files") - # Try and read as a superstructure file - # Choose a different routine for reading the file dependent on file type - if file_type_suffix == ".feather": - df = ss.ABFeatherImporter.read_file(path) - elif file_type_suffix.startswith(".xls"): - df = ss.import_from_excel(path, idx) - else: - df = ss.ABCSVImporter.read_file(path, separator=separator) - # Read in the file as a scenario flow table if the file is arranged as one - if len(df.columns.intersection(ss.SUPERSTRUCTURE)) >= 12: - if df is None: - QtWidgets.QApplication.restoreOverrideCursor() - return - self.sync_superstructure(df) - # Read the file as a parameter scenario file if it is correspondingly arranged - elif len(df.columns.intersection({"Name", "Group"})) == 2: - # Try and read as parameter scenario file. - logger.info("Superstructure: Attempting to read as parameter scenario file.") - - if not df["Group"].dtype == object: - df["Group"] = df["Group"].astype(str) - - df = ss.parameters_to_sdf(df) - self.sync_superstructure(df) - - else: - # this is a wrong file type - msg = ( - "The Activity-Browser is attempting to import a scenario file.

During the attempted import" - " another file type was detected. Please check the file type of the attempted import, if it is" - " a scenario file make sure it contains a valid format.

" - "

A flow exchange scenario file requires the following headers:
" - + ss.edit_superstructure_for_string(sep=", ", fhighlight='"') - + "

" - "

A parameter scenario file requires the following:
" - + ss.edit_superstructure_for_string( - ["name", "group"], sep=", ", fhighlight='"' - ) - + "

" - ) - critical = ss.ABPopup.abCritical( - "Wrong file type", msg, QtWidgets.QPushButton("Cancel") - ) - QtWidgets.QApplication.restoreOverrideCursor() - critical.exec_() - return - except: - QtWidgets.QApplication.restoreOverrideCursor() - raise - - self.scenario_name.setText(path.name) - self.scenario_name.setToolTip(path.name) - self._parent.save_button(True) - - QtWidgets.QApplication.restoreOverrideCursor() - - def sync_superstructure(self, df: pd.DataFrame) -> None: - """synchronizes the contents of either a single, or multiple scenario files to create a single scenario - dataframe""" - QtWidgets.QApplication.restoreOverrideCursor() - df = self.scenario_db_check(df) - QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - df = ss.SuperstructureManager.fill_empty_process_keys_in_exchanges(df) - ss.SuperstructureManager.verify_scenario_process_keys(df) - df = ss.SuperstructureManager.check_duplicates(df) - # If we've cancelled the import then we don't want to load the dataframe - if df.empty: - return - self.scenario_df = df - cols = ss.scenario_names_from_df(self.scenario_df) - self.model.setDataFrame(pd.DataFrame(cols, columns=["Scenarios"])) - self._parent.combined_dataframe() - - def scenario_db_check(self, df: pd.DataFrame) -> pd.DataFrame: - dbs = set(df.loc[:, "from database"]).union(set(df.loc[:, "to database"])) - unlinkable = dbs.difference(bd.databases) - db_lst = list(bd.databases) - relink = [] - for db in unlinkable: - relink.append((db, db_lst)) - # check for databases in the scenario dataframe that cannot be linked to - if unlinkable: - dialog = ScenarioDatabaseDialog.construct_dialog(self._parent, relink) - if dialog.exec_() == QtWidgets.QDialog.Accepted: - # TODO On update to bw2.5 this should be changed to use the bw2data.utils.get_node method - return ss.scenario_replace_databases(df, dialog.relink) - # generate the required dialog - return df - - @property - def dataframe(self) -> pd.DataFrame: - if self.scenario_df.empty: - logger.debug("No data in scenario table {}, skipping".format(self.index + 1)) - return self.scenario_df - - -class ExcelReadDialog(QtWidgets.QDialog): - SUFFIXES = { - ".xls", - ".xlsx", - ".bz2", - ".zip", - ".gz", - ".xz", - ".tar", - ".csv", - ".feather", - } - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Select file to read") - - self.path_layout = QtWidgets.QGridLayout() - self.path = None - self.path_line = QtWidgets.QLineEdit() - self.path_line.setReadOnly(True) - self.path_line.textChanged.connect(self.changed) - self.path_btn = QtWidgets.QPushButton("Browse") - self.path_btn.clicked.connect(self.browse) - self.path_layout.addWidget(QtWidgets.QLabel("Path to file*"), 0, 0, 1, 1) - self.path_layout.addWidget(self.path_line, 0, 1, 1, 2) - self.path_layout.addWidget(self.path_btn, 0, 3, 1, 1) - self.path = QtWidgets.QWidget() - self.path.setLayout(self.path_layout) - - self.excel_option = QtWidgets.QHBoxLayout() - self.import_sheet = QtWidgets.QComboBox() - self.import_sheet.addItems(["-----"]) - self.import_sheet.setEnabled(True) - self.excel_option.addWidget( - QtWidgets.QLabel("Excel sheet name") - ) # , 0, 0, 1, 1) - self.excel_option.addWidget(self.import_sheet) # , 0, 1, 2, 1) - self.excel_sheet = QtWidgets.QWidget() - self.excel_sheet.setLayout(self.excel_option) - self.excel_sheet.setVisible(False) - - self.csv_option = QtWidgets.QHBoxLayout() - self.field_separator = QtWidgets.QComboBox() - for l, s in {";": ";", ",": ",", "tab": "\t"}.items(): - self.field_separator.addItem(l, s) - self.field_separator.setEnabled(True) - self.csv_option.addWidget( - QtWidgets.QLabel("Separator for csv") - ) # , 0, 0, 1, 1) - self.csv_option.addWidget(self.field_separator) # , 0, 1, 2, 1) - self.csv_separator = QtWidgets.QWidget() - self.csv_separator.setLayout(self.csv_option) - self.csv_separator.setVisible(False) - - self.complete = False - - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(self.complete) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - layout = QtWidgets.QVBoxLayout() - grid = QtWidgets.QVBoxLayout() - grid.addWidget(self.path) - grid.addWidget(self.excel_sheet) - grid.addWidget(self.csv_separator) - - input_box = QtWidgets.QGroupBox(self) - input_box.setLayout(grid) - layout.addWidget(input_box) - layout.addWidget(self.buttons) - self.setLayout(layout) - - def browse(self) -> None: - path, _ = QtWidgets.QFileDialog.getOpenFileName( - parent=self, - caption="Select scenario template file", - filter="Excel (*.xlsx);; feather (*.feather);; CSV and Archived (*.csv *.zip *.tar *.bz2 *.gz *.xz);; All Files (*.*)", - selectedFilter="All Files (*.*)", - ) - if path: - self.path_line.setText(path) - - def update_combobox(self, file_path) -> None: - self.import_sheet.blockSignals(True) - self.import_sheet.clear() - names = ss.get_sheet_names(file_path) - self.import_sheet.addItems(names) - self.import_sheet.blockSignals(False) - - def changed(self) -> None: - """Determine if selected path is valid.""" - self.path = Path(self.path_line.text()) - self.complete = all( - [self.path.exists(), self.path.is_file(), self.path.suffix in self.SUFFIXES] - ) - if self.complete and self.path.suffix.startswith(".xls"): - self.update_combobox(self.path) - self.excel_sheet.setVisible(self.import_sheet.count() > 0) - self.csv_separator.setVisible(False) - elif self.complete and self.path.suffix in { - ".csv", - ".zip", - ".tar", - ".bz2", - ".gz", - ".xz", - }: - self.csv_separator.setVisible(True) - self.excel_sheet.setVisible(False) - else: - self.csv_separator.setVisible(False) - self.excel_sheet.setVisible(False) - self.buttons.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(self.complete) - - -class ScenarioDatabaseDialog(QtWidgets.QDialog): - """ - Displays the possible databases for relinking the exchanges for a given activity - """ - - def __init__(self, parent: QtWidgets.QWidget = None): - super().__init__(parent) - self.setWindowTitle("Linking scenario databases") - - self.label = QtWidgets.QLabel( - "The following database(s) in the scenario file cannot be found in your project.\n\n" - "Please indicate the corresponding database(s), or cancel the import if this is not" - " possible. (Warning: this process may take a few minutes for large scenario files)" - ) - - self.label_choices = [] - self.grid_box = QtWidgets.QGroupBox("DatabasesPane:") - self.grid = QtWidgets.QGridLayout() - self.grid_box.setLayout(self.grid) - - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.label) - layout.addWidget(self.grid_box) - layout.addWidget(self.buttons) - self.setLayout(layout) - - @property - def relink(self) -> dict: - """Returns a dictionary of str -> str key/values, showing which keys - should be linked to which values. - - Only returns key/value pairs if they differ. - """ - return { - label.text(): combo.currentText() - for label, combo in self.label_choices - if label.text() != combo.currentText() - } - - @classmethod - def construct_dialog(cls, parent: QtWidgets.QWidget = None, options: list = None) -> "ScenarioDatabaseDialog": - obj = cls(parent) - # Start at 1 because row 0 is taken up by the db_label - for i, item in enumerate(options): - label = QtWidgets.QLabel(item[0]) - combo = QtWidgets.QComboBox() - combo.addItems(item[1]) - combo.setCurrentIndex(0) - obj.label_choices.append((label, combo)) - obj.grid.addWidget(label, i, 0, 1, 2) - obj.grid.addWidget(combo, i, 2, 1, 2) - obj.updateGeometry() - return obj - diff --git a/activity_browser/app/pages/impact_category_details/__init__.py b/activity_browser/app/pages/impact_category_details/__init__.py deleted file mode 100644 index edf27be8d..000000000 --- a/activity_browser/app/pages/impact_category_details/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .impact_category_details import ImpactCategoryDetailsPage \ No newline at end of file diff --git a/activity_browser/app/pages/impact_category_details/impact_category_details.py b/activity_browser/app/pages/impact_category_details/impact_category_details.py deleted file mode 100644 index e46fd9537..000000000 --- a/activity_browser/app/pages/impact_category_details/impact_category_details.py +++ /dev/null @@ -1,307 +0,0 @@ -from qtpy import QtWidgets, QtGui, QtCore -from qtpy.QtCore import Qt -from loguru import logger - -import bw2data as bd -import pandas as pd - -from activity_browser import app -from activity_browser.ui import widgets, icons, delegates, core -from activity_browser.bwutils.commontasks import is_node_biosphere - -from .impact_category_header import ImpactCategoryHeader - - -class ImpactCategoryDetailsPage(QtWidgets.QWidget): - def __init__(self, name: tuple, parent=None): - super().__init__(parent) - self.name = name - self.impact_category = bd.Method(name) - self.is_editable = False - - self.setObjectName(" | ".join(name)) - - self.header = ImpactCategoryHeader(self) - - self.view = CharacterizationFactorsView(self) - self.model = CharacterizationFactorsModel(page=self) - self.view.setModel(self.model) - - self.build_layout() - self.connect_signals() - self.sync() - - def connect_signals(self): - app.signals.method.renamed.connect(self.on_method_renamed) - app.signals.method.deleted.connect(self.on_method_deleted) - app.signals.meta.methods_changed.connect(self.sync) - - def on_method_renamed(self, old_name, new_name): - if self.name == old_name: - self.name = new_name - self.setObjectName(" | ".join(new_name)) - self.setWindowTitle(" | ".join(new_name)) - - def on_method_deleted(self, method): - if method.name == self.name: - self.deleteLater() - - def sync(self): - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - if self.name not in bd.methods: - self.deleteLater() - return - - self.impact_category = bd.Method(self.name) - df = self.build_df() - df.reset_index(drop=True, inplace=True) - self.model.set_dataframe(df) - self.header.sync() - - def build_layout(self): - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.header) - layout.addWidget(widgets.ABHLine(self)) - layout.addWidget(self.view) - self.setLayout(layout) - - def build_df(self): - df = pd.DataFrame(self.impact_category.load(), columns=["id", "data"]) - df["amount"] = df["data"].apply(lambda x: x if isinstance(x, (float, int)) else x.get("amount")) - df["uncertainty"] = df["data"].apply(self.uncertainty_from_cf) - - other = app.metadata.dataframe[["id", "name", "categories", "database", "unit"]] - - df = df.merge(other, left_on="id", right_on="id").rename(columns={"id": "_id", "data": "_cf"}) - df["_impact_category_name"] = [self.name for i in range(len(df))] - df["_editable"] = self.is_editable - - cols = ["name", "categories", "database", "amount", "unit", "uncertainty", "_id", "_impact_category_name", "_cf", "_editable"] - return df[cols] - - def uncertainty_from_cf(self, cf): - if isinstance(cf, dict): - uncertainty_keys = { - "uncertainty type", - "loc", - "scale", - "shape", - "minimum", - "maximum", - "negative", - } - return {k: v for k, v in cf.items() if k in uncertainty_keys} - return 0 - - -class CharacterizationFactorsView(widgets.ABTreeView): - defaultColumnDelegates = { - "amount": delegates.FloatDelegate, - "categories": delegates.ListDelegate, - "uncertainty": delegates.UncertaintyDelegate, - } - - class ContextMenu(widgets.ABMenu): - menuSetup = [ - lambda m: m.add(app.actions.CFRemove, m.impact_category_name, m.char_factors, - enable=bool(m.char_factors) and m.is_editable, - text="Remove characterization factor(s)"), - ] - - @property - def is_editable(self): - table_view: CharacterizationFactorsView = self.parent() - return table_view.page.is_editable - - @property - def impact_category_name(self): - table_view: CharacterizationFactorsView = self.parent() - return table_view.page.name - - @property - def char_factors(self): - table_view: CharacterizationFactorsView = self.parent() - table_model: CharacterizationFactorsModel = table_view.model() - - selected_indices = table_view.selectedIndexes() - ids = table_model.values_from_indices("_id", selected_indices) - cfs = table_model.values_from_indices("_cf", selected_indices) - return list(zip(ids, cfs)) - - def __init__(self, parent): - super().__init__(parent) - self.setAcceptDrops(True) - self.setSortingEnabled(True) - self.overlay = None - - @property - def page(self): - """Returns the ImpactCategoryDetailsPage associated with the view.""" - return self.parent() - - def dragEnterEvent(self, event): - """ - Handles the drag enter event. - - Args: - event: The drag enter event. - """ - if not self.parent().is_editable: - event.ignore() - return - - if event.mimeData().hasFormat("application/bw-nodekeylist"): - self.overlay = widgets.ABDropOverlay(self) - self.overlay.show() - event.accept() - else: - event.ignore() - - def dragMoveEvent(self, event): - """Handles the drag move event - required for proper drop indicator.""" - if not self.parent().is_editable: - event.ignore() - return - - if event.mimeData().hasFormat("application/bw-nodekeylist"): - event.accept() - else: - event.ignore() - - def dragLeaveEvent(self, event): - """ - Handles the drag leave event. - - Args: - event: The drag leave event. - """ - if not self.overlay is None: - # Reset the palette on drag leave - self.overlay.deleteLater() - self.overlay = None - - def dropEvent(self, event): - """ - Handles the drop event. - - Args: - event: The drop event. - """ - self.overlay.deleteLater() - self.overlay = None - - keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - - # Filter to only biosphere flows - biosphere_keys = [key for key in keys if is_node_biosphere(key)] - - if biosphere_keys: - app.actions.CFNew.run(self.parent().name, biosphere_keys) - - -class CharacterizationFactorsModel(core.ABTreeModel): - """ - A model representing the characterization factors data. - """ - def __init__(self, page: ImpactCategoryDetailsPage): - super().__init__(parent=page, enable_sorting=True) - self.page = page - - def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: - """ - Sorts the model based on the given column and order. - - Args: - column (int): The column index to sort by. - order (Qt.SortOrder): The order to sort (ascending or descending). - """ - column_name = self.columns()[column] - if column_name == "uncertainty": - return - super().sort(column, order) - - def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: - """ - Sets the data for the given index. - - Args: - index (QtCore.QModelIndex): The index to set data for. - value: The value to set. - role (int): The role for which to set the data. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if role != Qt.ItemDataRole.EditRole: - return False - - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return False - - if column_name == "amount": - app.actions.CFAmountModify.run(row["_impact_category_name"], row["_id"], value) - return True - - if column_name == "uncertainty": - app.actions.CFUncertaintyModify.run( - row["_impact_category_name"], [(row["_id"], row["_cf"])], uncertainty_dict=value - ) - return True - - return False - - def decorationData(self, index: QtCore.QModelIndex) -> any: - """ - Provides decoration data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide decoration data. - - Returns: - The decoration data for the index. - """ - column_name = self.column_name(index) - if column_name == "name": - return icons.qicons.biosphere - - return None - - def fontData(self, index: QtCore.QModelIndex) -> any: - """ - Provides font data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide font data. - - Returns: - QtGui.QFont: The font data for the index. - """ - column_name = self.column_name(index) - if column_name == "name": - font = QtGui.QFont() - font.setWeight(QtGui.QFont.Weight.DemiBold) - return font - - return None - - def indexEditable(self, index): - """ - Returns whether the index is editable. - - Args: - index (QtCore.QModelIndex): The index to check. - - Returns: - bool: True if the index is editable, False otherwise. - """ - column_name = self.column_name(index) - # Allow editing for amount and uncertainty if editable - if column_name in ["amount", "uncertainty"] and self.get(index, "_editable"): - return True - - return False - diff --git a/activity_browser/app/pages/impact_category_details/impact_category_header.py b/activity_browser/app/pages/impact_category_details/impact_category_header.py deleted file mode 100644 index 492728e7c..000000000 --- a/activity_browser/app/pages/impact_category_details/impact_category_header.py +++ /dev/null @@ -1,192 +0,0 @@ -from qtpy import QtWidgets, QtCore -from loguru import logger - -from activity_browser import app -from activity_browser.ui import widgets - - -class ImpactCategoryHeader(QtWidgets.QWidget): - - def __init__(self, parent: QtWidgets.QWidget): - """ - Initializes the ImpactCategoryHeader widget with a stack layout - that switches between editable and view-only headers. - - Args: - parent (QtWidgets.QWidget): The parent widget. - """ - super().__init__(parent) - self.impact_category = parent.impact_category - - # Set size policy to only take needed vertical space - self.setSizePolicy( - QtWidgets.QSizePolicy.Policy.Expanding, - QtWidgets.QSizePolicy.Policy.Maximum - ) - - # Create stack layout to hold both header types - self.stack = QtWidgets.QStackedLayout() - self.stack.setContentsMargins(0, 0, 0, 0) - self.stack.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetMinimumSize) - - # Create both header widgets - self.view_only_header = ViewOnlyHeader(self) - self.editable_header = EditableHeader(self) - - # Add headers to stack - self.stack.addWidget(self.view_only_header) # Index 0 - self.stack.addWidget(self.editable_header) # Index 1 - - self.setLayout(self.stack) - - def sync(self): - """ - Synchronizes the widget with the current state of the impact category. - Switches between editable and view-only headers based on edit mode. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - self.impact_category = self.parent().impact_category - - # Update both headers with current data - self.view_only_header.sync() - self.editable_header.sync() - - # Switch to appropriate header based on edit mode - if self.parent().is_editable: - self.stack.setCurrentIndex(1) # Show editable header - else: - self.stack.setCurrentIndex(0) # Show view-only header - - def on_editable_changed(self): - """ - Called when the edit button is clicked. - Notifies the parent page to update the view accordingly. - """ - self.parent().is_editable = not self.parent().is_editable - self.parent().sync() - - -class ViewOnlyHeader(QtWidgets.QWidget): - """ - A read-only header widget that displays impact category information. - """ - - def __init__(self, parent: ImpactCategoryHeader): - """ - Initializes the view-only header. - - Args: - parent (ImpactCategoryHeader): The parent header widget. - """ - super().__init__(parent) - self.grid = QtWidgets.QGridLayout() - self.grid.setContentsMargins(0, 5, 0, 5) - self.grid.setSpacing(10) - self.grid.setColumnStretch(0, 0) # Column 0 doesn't stretch - self.grid.setColumnStretch(1, 1) # Column 1 takes remaining space - self.grid.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) - self.setLayout(self.grid) - - # Create widgets - self.name_label = QtWidgets.QLabel(self) - self.unit_label = QtWidgets.QLabel(self) - self.edit_button = QtWidgets.QPushButton("Edit Impact Category", self) - self.edit_button.clicked.connect(parent.on_editable_changed) - - # Layout widgets - self.grid.addWidget(widgets.ABLabel.demiBold("Name:", self), 0, 0) - self.grid.addWidget(self.name_label, 0, 1) - self.grid.addWidget(self.edit_button, 0, 1, QtCore.Qt.AlignmentFlag.AlignRight) - self.grid.addWidget(widgets.ABLabel.demiBold("Unit:", self), 1, 0) - self.grid.addWidget(self.unit_label, 1, 1) - - def sync(self): - """ - Updates the displayed information from the current impact category. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - impact_category = self.parent().impact_category - self.name_label.setText(" | ".join(impact_category.name)) - self.unit_label.setText(impact_category.metadata.get("unit", "Undefined")) - - -class EditableHeader(QtWidgets.QWidget): - """ - An editable header widget that allows modifying impact category information. - """ - - def __init__(self, parent: ImpactCategoryHeader): - """ - Initializes the editable header. - - Args: - parent (ImpactCategoryHeader): The parent header widget. - """ - super().__init__(parent) - self.grid = QtWidgets.QGridLayout() - self.grid.setContentsMargins(0, 5, 0, 5) - self.grid.setSpacing(10) - self.grid.setColumnStretch(0, 0) # Column 0 doesn't stretch - self.grid.setColumnStretch(1, 1) # Column 1 takes remaining space - self.grid.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) - self.setLayout(self.grid) - - # Create widgets - self.name_label = QtWidgets.QLabel(self) - self.name_label.linkActivated.connect(self._rename_method) - self.unit_edit = ImpactCategoryUnit(self) - self.done_button = QtWidgets.QPushButton("Done editing", self) - self.done_button.clicked.connect(parent.on_editable_changed) - - # Layout widgets - self.grid.addWidget(widgets.ABLabel.demiBold("Name:", self), 0, 0) - self.grid.addWidget(self.name_label, 0, 1) - self.grid.addWidget(self.done_button, 0, 1, QtCore.Qt.AlignmentFlag.AlignRight) - self.grid.addWidget(widgets.ABLabel.demiBold("Unit:", self), 1, 0) - self.grid.addWidget(self.unit_edit, 1, 1) - - def sync(self): - """ - Updates the displayed information from the current impact category. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - impact_category = self.parent().impact_category - self.name_label.setText(f"{' | '.join(impact_category.name)}") - self.unit_edit.setText(impact_category.metadata.get("unit", "Undefined")) - - def _rename_method(self): - """ - Triggers the method rename action. - """ - app.actions.MethodRename.run(self.parent().impact_category.name) - - -class ImpactCategoryUnit(QtWidgets.QLineEdit): - """ - A line edit widget for editing the impact category unit. - """ - - def __init__(self, parent: EditableHeader): - """ - Initializes the unit edit widget. - - Args: - parent (EditableHeader): The parent editable header widget. - """ - super().__init__(parent) - self.editingFinished.connect(self.change_unit) - - def change_unit(self): - """ - Updates the impact category unit when editing is finished. - """ - impact_category = self.parent().parent().impact_category - current_unit = impact_category.metadata.get("unit", "Undefined") - - if self.text() == current_unit: - return - - app.actions.MethodMetaModify.run(impact_category.name, "unit", self.text()) \ No newline at end of file diff --git a/activity_browser/app/pages/lca_results/LCA_results.py b/activity_browser/app/pages/lca_results/LCA_results.py deleted file mode 100644 index b475f091c..000000000 --- a/activity_browser/app/pages/lca_results/LCA_results.py +++ /dev/null @@ -1,2277 +0,0 @@ -from collections import namedtuple -from copy import deepcopy -from typing import List, Optional -from loguru import logger -from datetime import datetime - -import numpy as np -import pandas as pd -import bw2data as bd -from qtpy import QtCore, QtGui, QtWidgets - -from stats_arrays.errors import InvalidParamsError - -from activity_browser import app -from activity_browser.bwutils.commontasks import unit_of_method, get_LCIA_method_name_dict, format_activity_label -from activity_browser.bwutils.sensitivity_analysis import GlobalSensitivityAnalysis -from activity_browser.mod.bw2analyzer import ABContributionAnalysis -from activity_browser.ui import icons, widgets - -from .style import header, horizontal_line, vertical_line -from .tables import ContributionTable, InventoryTable, LCAResultsTable -from .plots import ContributionPlot, CorrelationPlot, LCAResultsBarChart, LCAResultsPlot, MonteCarloPlot -from .sankey_navigator import SankeyNavigatorWidget -from .tree_navigator import TreeNavigatorWidget - -ca = ABContributionAnalysis() - - -def get_header_layout(header_text: str) -> QtWidgets.QVBoxLayout: - vlayout = QtWidgets.QVBoxLayout() - vlayout.addWidget(header(header_text)) - vlayout.addWidget(horizontal_line()) - return vlayout - - -def get_header_layout_w_help(header_text: str, help_widget) -> QtWidgets.QVBoxLayout: - hlayout = QtWidgets.QHBoxLayout() - hlayout.addWidget(header(header_text)) - hlayout.addWidget(help_widget) - hlayout.setStretch(0, 1) - - vlayout = QtWidgets.QVBoxLayout() - vlayout.addLayout(hlayout) - vlayout.addWidget(horizontal_line()) - return vlayout - - -def get_unit(method: tuple, relative: bool = False) -> str: - """Get the unit for plot axis naming. - - Determine the unit based on whether a plot is shown: - - for a number of reference flows - - for a number of impact categories - and whether the axis are related to: - - relative or - - absolute numbers. - """ - if relative: - return "relative share" - if method: # for all reference flows - return unit_of_method(method) - return "units of each impact category" - - -# Special namedtuple for the LCAResults TabWidget. -Tabs = namedtuple( - "tabs", ("inventory", "results", "ef", "process", "sankey", "tree", "mc", "gsa") -) -Relativity = namedtuple("relativity", ("relative", "absolute")) -TotalMenu = namedtuple("total_menu", ("score", "range")) -ExportTable = namedtuple("export_table", ("label", "copy", "csv", "excel")) -ExportPlot = namedtuple("export_plot", ("label", "png", "svg")) -PlotTableCheck = namedtuple("plot_table_space", ("plot", "table", "invert")) -Combobox = namedtuple( - "combobox_menu", - ( - "func", - "func_label", - "method", - "method_label", - "agg", - "agg_label", - "scenario", - "scenario_label", - ), -) - - -class LCAResultsPage(QtWidgets.QTabWidget): - """Class for the main 'LCA Results' tab. - - Shows: - One sub-tab for each calculation setup - For each calculation setup-tab one array of relevant tabs. - """ - - update_scenario_box_index: QtCore.SignalInstance = QtCore.Signal(int) - - def __init__(self, cs_name, mlca, contributions, mc, parent=None): - super().__init__(parent) - self.setObjectName(f"{cs_name}-{datetime.now().strftime('%H:%M:%S')}") - self.setWindowTitle(f"{cs_name} [{datetime.now().strftime('%H:%M')}]") - - self.cs_name, self.mlca, self.contributions, self.mc = cs_name, mlca, contributions, mc - self.cs = bd.calculation_setups[self.cs_name] - self.has_scenarios: bool = hasattr(mlca, "scenario_names") - self.method_dict = get_LCIA_method_name_dict(self.mlca.methods) - self.single_func_unit = len(self.mlca.func_units) == 1 - self.single_method = len(self.mlca.methods) == 1 - - self.setMovable(True) - self.setVisible(False) - self.visible = False - - self.tabs = Tabs( - inventory=InventoryTab(self), - results=LCAResultsTab(self), - ef=ElementaryFlowContributionTab(self), - process=ProcessContributionsTab(self), - # ft=FirstTierContributionsTab(self.cs_name, parent=self), - sankey=SankeyNavigatorWidget(self.cs_name, parent=self), - tree=TreeNavigatorWidget(self.cs_name, parent=self), - mc=MonteCarloTab( - self - ), # mc=None if self.mc is None else MonteCarloTab(self), - gsa=GSATab(self), - ) - self.tab_names = Tabs( - inventory="Inventory", - results="LCA Results", - ef="EF Contributions", - process="Process Contributions", - # ft="FT Contributions", - sankey="Sankey", - tree="Tree", - mc="Monte Carlo", - gsa="Sensitivity Analysis", - ) - self.setup_tabs() - self.setCurrentWidget(self.tabs.results) - self.currentChanged.connect(self.generate_content_on_click) - - def setup_tabs(self): - """Have all the tabs pull in their required data and add them.""" - self._update_tabs() - for name, tab in zip(self.tab_names, self.tabs): - if tab: - self.addTab(tab, name) - if hasattr(tab, "configure_scenario"): - tab.configure_scenario() - - def _update_tabs(self): - """Update each sub-tab that can be updated.""" - for tab in self.tabs: - if tab and hasattr(tab, "update_tab"): - tab.update_tab() - self.tabs.sankey.update_calculation_setup(cs_name=self.cs_name) - - @QtCore.Slot(int, name="updateUnderlyingMatrices") - def update_scenario_data(self, index: int) -> None: - """Will calculate which scenario array to use and update all child tabs.""" - if index == self.mlca.current: - return - self.mlca.set_scenario(index) - self._update_tabs() - self.update_scenario_box_index.emit(index) - - @QtCore.Slot(int, name="generateSankeyOnClick") - def generate_content_on_click(self, index): - if index == self.indexOf(self.tabs.sankey): - 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() - - if index == self.indexOf(self.tabs.tree): - if not self.tabs.tree.has_rendered_once: - logger.info("Generating Tree Tab") - self.tabs.tree.new_tree() - - @QtCore.Slot(name="lciaScenarioExport") - def generate_lcia_scenario_csv(self): - """Create a dataframe of the impact category results for all reference flows, - impact categories and scenarios, then call the 'export to csv' - """ - df = self.mlca.lca_scores_to_dataframe() - filepath, _ = QtWidgets.QFileDialog.getSaveFileName( - parent=self, - caption="Choose location to save lca results", - filter="Comma Separated Values (*.csv);; All Files (*.*)", - ) - if filepath: - if not filepath.endswith(".csv"): - filepath += ".csv" - df.to_csv(filepath) - - @QtCore.Slot(name="lciaScenarioExport") - def generate_lcia_scenario_excel(self): - """Create a dataframe of the impact category results for all reference flows, - impact categories and scenarios, then call the 'export to excel' - """ - df = self.mlca.lca_scores_to_dataframe() - filepath, _ = QtWidgets.QFileDialog.getSaveFileName( - parent=self, - caption="Choose location to save lca results", - filter="Excel (*.xlsx);; All Files (*.*)", - ) - if filepath: - if not filepath.endswith(".xlsx"): - filepath += ".xlsx" - df.to_excel(filepath) - - -class NewAnalysisTab(QtWidgets.QWidget): - """Parent class around which all sub-tabs are built.""" - explain_text = "I explain what happens here" - - def __init__(self, parent=None): - super().__init__(parent) - - self.help_button: Optional[QtWidgets.QToolBar] = None - - self.parent = parent - self.has_scenarios = self.parent.has_scenarios - - # Important variables optionally used in subclasses - self.table: Optional[QtWidgets.QTableView] = None - self.plot: Optional[QtWidgets.QWidget] = None - self.plot_table: Optional[PlotTableCheck] = None - self.relativity: Optional[Relativity] = None - self.relative: Optional[bool] = None - self.total_menu: Optional[TotalMenu] = None - self.total_range: Optional[bool] = None - self.score_marker: Optional[bool] = None - self.export_plot: Optional[ExportPlot] = None - self.export_table: Optional[ExportTable] = None - - self.scenario_box = SmallComboBox() - self.pt_layout = QtWidgets.QVBoxLayout() - self.layout = QtWidgets.QVBoxLayout() - self.setLayout(self.layout) - - def build_main_space(self, invertable: bool = False) -> QtWidgets.QScrollArea: - """Assemble main space where plots, tables and relevant options are shown.""" - space = QtWidgets.QScrollArea() - widget = QtWidgets.QWidget() - self.pt_layout.setAlignment(QtCore.Qt.AlignTop) - widget.setLayout(self.pt_layout) - space.setWidget(widget) - space.setWidgetResizable(True) - - # Option switches - self.plot_table = PlotTableCheck(QtWidgets.QCheckBox("Plot"), QtWidgets.QCheckBox("Table"), None) - if invertable: - self.plot_table = PlotTableCheck( - QtWidgets.QCheckBox("Plot"), QtWidgets.QCheckBox("Table"), QtWidgets.QCheckBox("Invert") - ) - self.plot_table.invert.setChecked(False) - self.plot_table.invert.stateChanged.connect(self.invert_plot) - self.plot_table.plot.setChecked(True) - self.plot_table.table.setChecked(True) - self.plot_table.table.stateChanged.connect(self.space_check) - self.plot_table.plot.stateChanged.connect(self.space_check) - - # Assemble option row - row = QtWidgets.QHBoxLayout() - row.addWidget(self.plot_table.plot) - row.addWidget(self.plot_table.table) - row.addWidget(vertical_line()) - if invertable: - row.addWidget(self.plot_table.invert) - if self.relativity: - row.addWidget(self.relativity.relative) - row.addWidget(self.relativity.absolute) - self.relativity.relative.toggled.connect(self.relativity_check) - if self.total_menu: - row.addWidget(vertical_line()) - row.addWidget(self.total_menu.score) - row.addWidget(self.total_menu.range) - self.total_menu.range.toggled.connect(self.total_check) - if hasattr(self, "score_mrk_checkbox"): - row.addStretch() - row.addWidget(self.score_mrk_checkbox) - self.score_mrk_checkbox.toggled.connect(self.score_mrk_check) - if not hasattr(self, "score_mrk_checkbox"): - row.addStretch() - - # Assemble Table and Plot area - if self.table and self.plot: - self.pt_layout.addLayout(row) - if self.plot: - self.pt_layout.addWidget(self.plot, 1) - if self.table: - self.pt_layout.addWidget(self.table) - self.pt_layout.addStretch() - return space - - @QtCore.Slot(name="invertPlot") - def invert_plot(self): - self.plot_inversion = self.plot_table.invert.isChecked() - self.space_check() - self.update_plot() - - @QtCore.Slot(name="checkboxChanges") - def space_check(self): - """Show graph and/or table, whichever is selected. - - Can also hide both, if you want to do that. - """ - self.table.setVisible(self.plot_table.table.isChecked()) - self.plot.setVisible(self.plot_table.plot.isChecked()) - - @QtCore.Slot(bool, name="isRelativeToggled") - def relativity_check(self, checked: bool): - """Check if the relative or absolute option is selected.""" - self.relative = checked - self.update_tab() - - @QtCore.Slot(bool, name="isTotalToggled") - def total_check(self, checked: bool): - """Check if the relative or absolute option is selected.""" - self.total_range = checked - self.update_tab() - - @QtCore.Slot(bool, name="isScoreMarkerToggled") - def score_mrk_check(self, checked: bool): - self.score_marker = checked - - self.update_tab() - - def get_scenario_labels(self) -> List[str]: - """Get scenario labels if scenarios are used.""" - return self.parent.mlca.scenario_names if self.has_scenarios else [] - - def configure_scenario(self): - """Determine if scenario Qt widgets are visible or not and retrieve - scenario labels for the selection drop-down box. - """ - if self.scenario_box: - self.scenario_box.setVisible(self.has_scenarios) - self.update_combobox(self.scenario_box, self.get_scenario_labels()) - - @staticmethod - @QtCore.Slot(int, name="setBoxIndex") - def set_combobox_index(box: QtWidgets.QComboBox, index: int) -> None: - """Update the index on the given QComboBox without sending a signal.""" - box.blockSignals(True) - box.setCurrentIndex(index) - box.blockSignals(False) - - @staticmethod - def update_combobox(box: QtWidgets.QComboBox, labels: List[str]) -> None: - """Update the combobox menu.""" - box.blockSignals(True) - box.clear() - box.insertItems(0, labels) - box.blockSignals(False) - - def update_tab(self): - """Update the plot and table if they are present.""" - if self.plot and self.plot.isVisible: - self.update_plot() - if self.table and self.table.isVisible: - self.update_table() - if self.plot and self.plot.isVisible and self.table and self.table.isVisible: - self.space_check() - - def update_table(self, *args, **kwargs): - """Update the table.""" - self.table.model.sync(*args, **kwargs) - - def update_plot(self, *args, **kwargs): - """Update the plot.""" - self.plot.plot(*args, **kwargs) - self.export_plot.png.clicked.connect(self.plot.to_png) - self.export_plot.svg.clicked.connect(self.plot.to_svg) - - def build_export( - self, has_table: bool = True, has_plot: bool = True - ) -> QtWidgets.QHBoxLayout: - """Construct a custom export button layout. - - Produces layout with buttons for export of relevant sections (plot, table). - Options for figure are: - .png (image format useful for computer generated graphics) - .svg (scalable vector graphic, image is not pixels but data on where lines are, - useful in reports) - Options for Table are: - copy (copies the table to clipboard) - .csv (a comma separated values file of the table, useful for data storage) - Excel (an excel file, useful for exchanging with people and making visualizations) - """ - export_menu = QtWidgets.QHBoxLayout() - - # Export Plot - if has_plot: - plot_layout = QtWidgets.QHBoxLayout() - self.export_plot = ExportPlot( - QtWidgets.QLabel("Export plot:"), - QtWidgets.QPushButton(".png"), - QtWidgets.QPushButton(".svg"), - ) - self.export_plot.png.clicked.connect(self.plot.to_png) - self.export_plot.svg.clicked.connect(self.plot.to_svg) - for obj in self.export_plot: - plot_layout.addWidget(obj) - export_menu.addLayout(plot_layout) - - # Add seperator if both table and plot exist - if has_table and has_plot: - export_menu.addWidget(vertical_line()) - - # Export Table - if has_table: - table_layout = QtWidgets.QHBoxLayout() - self.export_table = ExportTable( - QtWidgets.QLabel("Export table:"), - QtWidgets.QPushButton("Copy"), - QtWidgets.QPushButton(".csv"), - QtWidgets.QPushButton("Excel"), - ) - self.export_table.copy.clicked.connect(self.table.to_clipboard) - self.export_table.csv.clicked.connect(self.table.to_csv) - self.export_table.excel.clicked.connect(self.table.to_excel) - for obj in self.export_table: - table_layout.addWidget(obj) - export_menu.addLayout(table_layout) - - export_menu.addStretch() - return export_menu - - def explanation(self): - """Builds and shows a message box containing whatever text is set - on self.explain_text - """ - return QtWidgets.QMessageBox.question( - self, "Explanation", self.explain_text, QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.Ok - ) - - -class InventoryTab(NewAnalysisTab): - """Class for the 'Inventory' sub-tab. - - This tab allows for investigation of the inventories of the calculation. - - Shows: - Option to choose between 'Biosphere flows' and 'Technosphere flows' - Inventory table for either 'Biosphere flows' or 'Technosphere flows' - Export options - """ - - def __init__(self, parent=None): - super().__init__(parent) - self.df_biosphere = None - self.df_technosphere = None - - self.layout.addLayout(get_header_layout("Inventory")) - self.bio_tech_button_group = QtWidgets.QButtonGroup() - self.bio_categorisation_factor_group = QtWidgets.QComboBox() - # buttons - button_layout = QtWidgets.QHBoxLayout() - self.radio_button_biosphere = QtWidgets.QRadioButton("Biosphere flows") - self.radio_button_biosphere.setChecked(True) - - self.radio_button_technosphere = QtWidgets.QRadioButton("Technosphere flows") - self.remove_zeros_checkbox = QtWidgets.QCheckBox("Remove '0' values") - self.remove_zero_state = False - - self.categorisation_factor_filters = [ - "No filtering with categorisation factors", - "Flows without categorisation factors", - "Flows with categorisation factors", - ] - self.categorisation_factor_state = None - self.old_categorisation_factor_state = self.categorisation_factor_state - - self.last_remove_zero_state = self.remove_zero_state - self.remove_zeros_checkbox.setChecked(self.remove_zero_state) - self.remove_zeros_checkbox.setToolTip( - "Choose whether to show '0' values or not.\n" - "When selected, '0' values are not shown.\n" - "Rows are only removed when all reference flows are '0'." - ) - self.scenario_label = QtWidgets.QLabel("Scenario:") - - # Group the radio buttons into the appropriate groups for the window - self.update_combobox( - self.bio_categorisation_factor_group, self.categorisation_factor_filters - ) - self.bio_categorisation_factor_group.setMaximumWidth(300) - self.bio_categorisation_factor_group.setSizeAdjustPolicy( - QtWidgets.QComboBox.AdjustToContentsOnFirstShow - ) - - # Setup the Qt environment for the buttons, including the arrangement - self.categorisation_filter_layout = QtWidgets.QVBoxLayout() - self.categorisation_filter_layout.addWidget(QtWidgets.QLabel("Filter flows:")) - self.categorisation_filter_layout.addWidget( - self.bio_categorisation_factor_group - ) - self.categorisation_filter_box = QtWidgets.QWidget() - self.categorisation_filter_box.setLayout(self.categorisation_filter_layout) - self.categorisation_filter_box.setVisible(True) - self.categorisation_filter_with_flows = None - - button_layout.addWidget(self.radio_button_biosphere) - button_layout.addWidget(self.radio_button_technosphere) - button_layout.addWidget(self.scenario_label) - button_layout.addWidget(self.scenario_box) - button_layout.addStretch(1) - button_layout.addWidget(self.remove_zeros_checkbox) - self.layout.addLayout(button_layout) - self.layout.addWidget(self.categorisation_filter_box) - # table - self.table = InventoryTable(self.parent) - self.table.table_name = "Inventory_" + self.parent.cs_name - self.layout.addWidget(self.table) - - self.layout.addLayout(self.build_export(has_plot=False, has_table=True)) - self.connect_signals() - - def connect_signals(self): - self.radio_button_biosphere.toggled.connect(self.button_clicked) - self.remove_zeros_checkbox.toggled.connect(self.remove_zeros_checked) - self.bio_tech_button_group.buttonClicked.connect( - self.toggle_categorisation_factor_filter_buttons - ) - self.bio_categorisation_factor_group.activated.connect( - self.add_categorisation_factor_filter - ) - if self.has_scenarios: - self.scenario_box.currentIndexChanged.connect( - self.parent.update_scenario_data - ) - self.parent.update_scenario_box_index.connect( - lambda index: self.set_combobox_index(self.scenario_box, index) - ) - - @QtCore.Slot(QtWidgets.QRadioButton, name="addCategorisationFactorFilter") - def add_categorisation_factor_filter(self, index: int): - if ( - self.bio_categorisation_factor_group.currentText() - == "Flows without categorisation factors" - ): - self.categorisation_filter_with_flows = False - self.categorisation_factor_state = False - elif ( - self.bio_categorisation_factor_group.currentText() - == "Flows with categorisation factors" - ): - self.categorisation_filter_with_flows = True - self.categorisation_factor_state = True - else: - self.categorisation_filter_with_flows = None - self.categorisation_factor_state = None - self.update_table() - self.old_categorisation_factor_state = self.categorisation_factor_state - - @QtCore.Slot(QtWidgets.QRadioButton, name="toggleCategorisationFactorFilterButtons") - def toggle_categorisation_factor_filter_buttons(self, bttn: QtWidgets.QRadioButton): - if bttn.text() == "Biosphere flows": - self.categorisation_filter_box.setVisible(True) - else: - self.categorisation_filter_box.setVisible(False) - self.categorisation_factor_state = None - - @QtCore.Slot(bool, name="isRemoveZerosToggled") - def remove_zeros_checked(self, toggled: bool): - """Update table according to remove-zero selected.""" - self.remove_zero_state = toggled - self.update_table() - self.last_remove_zero_state = self.remove_zero_state - - @QtCore.Slot(bool, name="isBiosphereToggled") - def button_clicked(self, toggled: bool): - """Update table according to radiobutton selected.""" - ext = "_Inventory" if toggled else "_Inventory_technosphere" - self.table.table_name = "{}{}".format(self.parent.cs_name, ext) - self.update_table() - - def configure_scenario(self): - """Allow scenarios options to be visible when used.""" - super().configure_scenario() - self.scenario_label.setVisible(self.has_scenarios) - - def update_tab(self): - """Update the tab.""" - self.clear_tables() - super().update_tab() - - def elementary_flows_contributing_to_IA_methods( - self, contributary: bool = True, bios: pd.DataFrame = None - ) -> pd.DataFrame: - """Returns a biosphere dataframe filtered for the presence in the impact assessment methods - Requires a boolean argument for whether those flows included in the impact assessment method - should be returned (True), or not (False) - """ - incl_flows = { - self.parent.contributions.inventory_data["biosphere"][1][k] - for mthd in self.parent.mlca.method_matrices - for k in mthd.indices - } - data = bios if bios is not None else self.df_biosphere - if contributary: - flows = incl_flows - else: - flows = ( - set(self.parent.contributions.inventory_data["biosphere"][1].values()) - ).difference(incl_flows) - return data.loc[data["id"].isin(flows)] - - def update_table(self): - """Update the table.""" - inventory = ( - "biosphere" if self.radio_button_biosphere.isChecked() else "technosphere" - ) - self.table.showing = inventory - # We handle both 'df_biosphere' and 'df_technosphere' variables here. - attr_name = "df_{}".format(inventory) - if ( - getattr(self, attr_name) is None - or self.remove_zero_state != self.last_remove_zero_state - or self.old_categorisation_factor_state != self.categorisation_factor_state - ): - setattr( - self, - attr_name, - self.parent.contributions.inventory_df(inventory_type=inventory), - ) - - # filter the biosphere flows for the relevance to the CFs - if ( - self.categorisation_filter_with_flows is not None - and inventory == "biosphere" - ): - self.df_biosphere = self.elementary_flows_contributing_to_IA_methods( - self.categorisation_filter_with_flows, self.df_biosphere - ) - - # filter the flows to remove those that have relevant exchanges - def filter_zeroes(df): - filter_on = [x for x in df.columns.tolist() if "|" in x] - return df[df[filter_on].sum(axis=1) != 0].reset_index(drop=True) - - if self.remove_zero_state and getattr(self, "df_biosphere") is not None: - self.df_biosphere = filter_zeroes(self.df_biosphere) - if self.remove_zero_state and getattr(self, "df_technosphere") is not None: - self.df_technosphere = filter_zeroes(self.df_technosphere) - - self._update_table(getattr(self, attr_name)) - - def clear_tables(self) -> None: - """Set the biosphere and technosphere to None.""" - self.df_biosphere, self.df_technosphere = None, None - - def _update_table(self, table: pd.DataFrame, drop: tuple = ("code", "id")): - """Update the table.""" - self.table.model.sync((table.drop(list(drop), axis=1)).reset_index(drop=True)) - - -class LCAResultsTab(NewAnalysisTab): - """Class for the 'LCA Results' sub-tab. - - This tab allows the user to get a basic overview of the results of the calculation setup. - - Shows: - 'Overview' and 'by impact category' options for different plots/graphs - Plots/graphs - Export buttons - """ - - def __init__(self, parent=None): - super().__init__(parent) - self.parent = parent - self.lca_scores_widget = LCAScoresTab(parent) - self.lca_overview_widget = LCIAResultsTab(parent) - - self.layout.setAlignment(QtCore.Qt.AlignTop) - self.layout.addLayout(get_header_layout("LCA Results")) - - # buttons - button_layout = QtWidgets.QHBoxLayout() - self.button_group = QtWidgets.QButtonGroup() - self.button_overview = QtWidgets.QRadioButton("Overview") - self.button_overview.setToolTip( - "Show a matrix of all reference flows and all impact categories" - ) - button_layout.addWidget(self.button_overview) - self.button_by_method = QtWidgets.QRadioButton("by impact category") - self.button_by_method.setToolTip( - "Show the impacts of each reference flow for the selected impact categories" - ) - self.button_by_method.setChecked(True) - self.scenario_label = QtWidgets.QLabel("Scenario:") - self.button_group.addButton(self.button_overview, 0) - self.button_group.addButton(self.button_by_method, 1) - button_layout.addWidget(self.button_by_method) - button_layout.addWidget(self.scenario_label) - button_layout.addWidget(self.scenario_box) - button_layout.addStretch(1) - self.layout.addLayout(button_layout) - - self.layout.addWidget(self.lca_scores_widget) - self.layout.addWidget(self.lca_overview_widget) - - self.button_clicked(False) - self.connect_signals() - - def connect_signals(self): - self.button_overview.toggled.connect(self.button_clicked) - if self.has_scenarios: - self.scenario_box.currentIndexChanged.connect( - self.parent.update_scenario_data - ) - self.parent.update_scenario_box_index.connect( - lambda index: self.set_combobox_index(self.scenario_box, index) - ) - self.button_by_method.toggled.connect( - lambda on_lcia: self.scenario_box.setHidden(on_lcia) - ) - self.button_by_method.toggled.connect( - lambda on_lcia: self.scenario_label.setHidden(on_lcia) - ) - - @QtCore.Slot(bool, name="overviewToggled") - def button_clicked(self, is_overview: bool): - self.lca_overview_widget.setVisible(is_overview) - self.lca_scores_widget.setHidden(is_overview) - - def configure_scenario(self): - """Allow scenarios options to be visible when used.""" - super().configure_scenario() - self.scenario_box.setHidden(self.button_by_method.isChecked()) - self.scenario_label.setHidden(self.button_by_method.isChecked()) - - def update_tab(self): - """Update the tab.""" - self.lca_scores_widget.update_tab() - self.lca_overview_widget.update_tab() - - -class LCAScoresTab(NewAnalysisTab): - """Class for when 'by impact category' is chosen in the 'LCA Results' sub-tab.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.parent = parent - - self.combobox_menu = QtWidgets.QHBoxLayout() - self.combobox_label = QtWidgets.QLabel("Choose impact category:") - self.combobox = QtWidgets.QComboBox() - self.combobox.scroll = False - self.combobox_menu.addWidget(self.combobox_label) - self.combobox_menu.addWidget(self.combobox, 1) - self.combobox_menu.addStretch(1) - self.layout.addLayout(self.combobox_menu) - - self.plot = LCAResultsBarChart(self.parent) - self.plot.plot_name = "LCA scores_" + self.parent.cs_name - self.layout.addWidget(self.plot) - - self.layout.addLayout(self.build_export(has_plot=True, has_table=False)) - - self.connect_signals() - - def connect_signals(self): - self.combobox.currentIndexChanged.connect(self.update_plot) - - def build_export( - self, has_table: bool = True, has_plot: bool = True - ) -> QtWidgets.QHBoxLayout: - """Add 3d excel export if scenario-type LCA is performed.""" - layout = super().build_export(has_table, has_plot) - if self.has_scenarios: - # Remove the last QSpacerItem from the layout, - stretch = layout.takeAt(layout.count() - 1) - # Then add the additional label and export btn, plus new stretch. - exp_layout = QtWidgets.QHBoxLayout() - exp_layout.addWidget(QtWidgets.QLabel("Export all data")) - - csv_btn = QtWidgets.QPushButton(".csv") - csv_btn.setToolTip( - "Include all reference flows, impact categories and scenarios" - ) - if self.parent: - csv_btn.clicked.connect(self.parent.generate_lcia_scenario_csv) - - excel_btn = QtWidgets.QPushButton("Excel") - excel_btn.setToolTip( - "Include all reference flows, impact categories and scenarios" - ) - if self.parent: - excel_btn.clicked.connect(self.parent.generate_lcia_scenario_excel) - - exp_layout.addWidget(csv_btn) - exp_layout.addWidget(excel_btn) - layout.addWidget(vertical_line()) - layout.addLayout(exp_layout) - layout.addSpacerItem(stretch) - return layout - - def update_tab(self): - """Update the tab.""" - self.update_combobox(self.combobox, [str(m) for m in self.parent.mlca.methods]) - super().update_tab() - - @QtCore.Slot(int, name="updatePlotWithIndex") - def update_plot(self, method_index: int = 0): - """Update the plot.""" - method = self.parent.mlca.methods[method_index] - df = self.parent.mlca.get_results_for_method(method_index) - labels = [ - format_activity_label(next(iter(fu.keys())), style="pnld") - for fu in self.parent.mlca.func_units - ] - idx = self.layout.indexOf(self.plot) - self.plot.figure.clf() - self.plot.setVisible(False) - self.plot.deleteLater() - self.plot = LCAResultsBarChart(self.parent) - self.layout.insertWidget(idx, self.plot) - super().update_plot(df, method=method, labels=labels) - self.updateGeometry() - self.plot.plot_name = "_".join([self.parent.cs_name, "LCA scores", str(method)]) - - -class LCIAResultsTab(NewAnalysisTab): - """Class for when 'Overview' is chosen in the 'LCA Results' sub-tab.""" - - def __init__(self, parent, **kwargs): - super(LCIAResultsTab, self).__init__(parent, **kwargs) - self.parent = parent - self.df = None - self.plot_inversion = False - - # if not self.parent.single_func_unit: - self.plot = LCAResultsPlot(self.parent) - self.plot.plot_name = self.parent.cs_name + "_LCIA results" - self.table = LCAResultsTable(self.parent) - self.table.table_name = self.parent.cs_name + "_LCIA results" - self.relative = False - - self.layout.addWidget(self.build_main_space(True)) - self.layout.addLayout(self.build_export(True, True)) - - def build_export( - self, has_table: bool = True, has_plot: bool = True - ) -> QtWidgets.QHBoxLayout: - """Add 3d excel export if scenario-type LCA is performed.""" - layout = super().build_export(has_table, has_plot) - if self.has_scenarios: - # Remove the last QSpacerItem from the layout, - stretch = layout.takeAt(layout.count() - 1) - # Then add the additional label and export btn, plus new stretch. - exp_layout = QtWidgets.QHBoxLayout() - exp_layout.addWidget(QtWidgets.QLabel("Export all data")) - - csv_btn = QtWidgets.QPushButton(".csv") - csv_btn.setToolTip( - "Include all reference flows, impact categories and scenarios" - ) - if self.parent: - csv_btn.clicked.connect(self.parent.generate_lcia_scenario_csv) - - excel_btn = QtWidgets.QPushButton("Excel") - excel_btn.setToolTip( - "Include all reference flows, impact categories and scenarios" - ) - if self.parent: - excel_btn.clicked.connect(self.parent.generate_lcia_scenario_excel) - - exp_layout.addWidget(csv_btn) - exp_layout.addWidget(excel_btn) - layout.addWidget(vertical_line()) - layout.addLayout(exp_layout) - layout.addSpacerItem(stretch) - return layout - - def update_tab(self): - self.df = self.parent.contributions.lca_scores_df(normalized=self.relative) - super().update_tab() - - def update_plot(self): - """Update the plot.""" - idx = self.pt_layout.indexOf(self.plot) - self.plot.figure.clf() - self.plot.setVisible(False) - self.plot.deleteLater() - self.plot = LCAResultsPlot(self.parent) - self.pt_layout.insertWidget(idx, self.plot) - super().update_plot(self.df, invert_plot=self.plot_inversion) - if self.pt_layout.parentWidget(): - self.pt_layout.parentWidget().updateGeometry() - - def update_table(self): - super().update_table(self.df) - -class SmallComboBox(QtWidgets.QComboBox): - """A small combo box that does not expand to fill the available space.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) - self.setMinimumWidth(100) - self.setMaximumWidth(200) - self.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContentsOnFirstShow) - - -class ContributionTab(NewAnalysisTab): - """Parent class for any 'XXX Contributions' sub-tab.""" - - def __init__(self, parent, **kwargs): - super().__init__(parent) - self.cutoff_menu = widgets.CutoffMenu(self, cutoff_value=0.05) - self.combobox_menu = Combobox( - func=QtWidgets.QComboBox(self), - func_label=QtWidgets.QLabel("Reference Flow:"), - method=SmallComboBox(self), - method_label=QtWidgets.QLabel("Impact Category:"), - agg=SmallComboBox(self), - agg_label=QtWidgets.QLabel("Aggregate by:"), - scenario=self.scenario_box, - scenario_label=QtWidgets.QLabel("Scenario:"), - ) - self.switch_label = QtWidgets.QLabel("Compare:") - self.switches = widgets.SwitchComboBox(self) - - self.relativity = Relativity( - QtWidgets.QRadioButton("Relative"), - QtWidgets.QRadioButton("Absolute"), - ) - self.relativity.relative.setChecked(True) - self.relative = True - self.relativity.relative.setToolTip( - "Show relative values (compare fraction of each contribution)" - ) - self.relativity.absolute.setToolTip( - "Show absolute values (compare magnitudes of each contribution)" - ) - self.relativity_group = QtWidgets.QButtonGroup(self) - self.relativity_group.addButton(self.relativity.relative) - self.relativity_group.addButton(self.relativity.absolute) - - self.total_menu = TotalMenu( - QtWidgets.QRadioButton("Score"), - QtWidgets.QRadioButton("Range"), - ) - self.total_menu.score.setChecked(True) - self.total_range = False - self.total_menu.score.setToolTip( - "Show the contributions relative to the total impact score.\n" - "e.g. total negative results is -2 and total positive results is 10, then score is 8 (-2 + 10)" - ) - self.total_menu.range.setToolTip( - "Show the contribution relative to the total range of results.\n" - "e.g. total negative results is -2 and total positive results is 10, then range is 12 (-2 * -1 + 10)" - ) - self.total_group = QtWidgets.QButtonGroup(self) - self.total_group.addButton(self.total_menu.score) - self.total_group.addButton(self.total_menu.range) - - self.score_marker = False - self.score_mrk_checkbox = QtWidgets.QCheckBox("Score Marker") - self.score_mrk_checkbox.setToolTip( - "Shows the score marker. When there are both positive and negative results,\n" - "this shows a marker where the total score is." - ) - self.score_mrk_checkbox.setChecked(self.score_marker) - - self.df = None - self.plot = ContributionPlot(self) - self.table = ContributionTable(self) - self.contribution_fn = None - self.has_method, self.has_func = False, False - self.unit = None - - self.has_been_opened = False - - # set-up the help button - self.explain_text = """ -

There are three ways of doing Contribtion Analysis in Activity Browser: -

- Elementary Flow (EF) Contributions

-

- Process Contributions

-

- First Tier (FT) Contributions

- - Detailed information on the different approaches provided in this wiki page about the different approaches. - -

You can manipulate the results in many ways with Activity Browser, read more on this wiki page - about manipulating results. - """ - - self.help_button = QtWidgets.QToolBar(self) - self.help_button.addAction( - icons.qicons.question, "Left click for help on Contribution Analysis Functions", self.explanation - ) - - def set_filename(self, optional_fields: dict = None): - """Given a dictionary of fields, put together a usable filename for the plot and table.""" - optional = optional_fields or {} - fields = ( - self.parent.cs_name, - self.contribution_fn, - optional.get("method"), - optional.get("functional_unit"), - self.unit, - ) - - filename = "_".join((str(x) for x in fields if x is not None)) - self.plot.plot_name, self.table.table_name = filename, filename - - def build_combobox( - self, has_method: bool = True, has_func: bool = False - ) -> QtWidgets.QHBoxLayout: - """Construct a horizontal layout for picking and choosing what data to show and how.""" - menu = QtWidgets.QHBoxLayout() - # Populate the drop-down boxes with their relevant values. - self.combobox_menu.func.addItems( - list(self.parent.mlca.func_unit_translation_dict.keys()) - ) - self.combobox_menu.method.addItems(list(self.parent.method_dict.keys())) - - menu.addWidget(self.switch_label) - menu.addWidget(self.switches) - menu.addWidget(vertical_line()) - menu.addWidget(self.combobox_menu.scenario_label) - menu.addWidget(self.combobox_menu.scenario) - menu.addWidget(self.combobox_menu.method_label) - menu.addWidget(self.combobox_menu.method) - menu.addWidget(self.combobox_menu.func_label) - menu.addWidget(self.combobox_menu.func) - menu.addWidget(self.combobox_menu.agg_label) - menu.addWidget(self.combobox_menu.agg) - menu.addStretch() - - self.has_method = has_method - self.has_func = has_func - return menu - - def configure_scenario(self): - """Supplement the superclass method because there are more things to hide in these tabs.""" - super().configure_scenario() - visible = self.has_scenarios - self.combobox_menu.scenario_label.setVisible(visible) - - @QtCore.Slot(int, name="changeComparisonView") - def toggle_comparisons(self, index: int): - self.toggle_func(index == self.switches.indexes.func) - self.toggle_method(index == self.switches.indexes.method) - self.toggle_scenario(index == self.switches.indexes.scenario) - self.update_tab() - - @QtCore.Slot(bool, name="hideScenarioCombo") - def toggle_scenario(self, active: bool): - """Allow scenarios options to be visible when used.""" - if self.has_scenarios: - self.combobox_menu.scenario.setHidden(active) - self.combobox_menu.scenario_label.setHidden(active) - - @QtCore.Slot(bool, name="hideFuCombo") - def toggle_func(self, active: bool): - self.combobox_menu.func.setHidden(active) - self.combobox_menu.func_label.setHidden(active) - - @QtCore.Slot(bool, name="hideMethodCombo") - def toggle_method(self, active: bool): - self.combobox_menu.method.setHidden(active) - self.combobox_menu.method_label.setHidden(active) - - @QtCore.Slot(name="comboboxTriggerUpdate") - def set_combobox_changes(self): - """Update fields based on user-made changes in combobox. - - Any trigger linked to this slot will cause the values in the - combobox objects to be read out (which comparison, drop-down indexes, - etc.) and fed into update calls. - """ - # gather the combobox values - method = self.parent.method_dict[self.combobox_menu.method.currentText()] - functional_unit = self.combobox_menu.func.currentText() - scenario = max(self.combobox_menu.scenario.currentIndex(), 0) # set scenario 0 if not initiated yet - aggregator = self.combobox_menu.agg.currentText() - - # set aggregator to None if unwanted - if aggregator == "none": - aggregator = None - - # initiate dict with the field we want to compare - compare_fields = {"aggregator": aggregator} - - # Determine which comparison is active and update the comparison. - if self.switches.currentIndex() == self.switches.indexes.func: - compare_fields.update({"method": method, "scenario": scenario}) - elif self.switches.currentIndex() == self.switches.indexes.method: - compare_fields.update( - {"functional_unit": functional_unit, "scenario": scenario} - ) - elif self.switches.currentIndex() == self.switches.indexes.scenario: - compare_fields.update( - { - "method": method, - "functional_unit": functional_unit, - } - ) - - # Determine the unit for the figure, update the filenames and the - # underlying dataframe. - self.unit = get_unit(compare_fields.get("method"), self.relative) - self.set_filename(compare_fields) - self.df = self.update_dataframe(**compare_fields) - - def connect_signals(self): - """Override the inherited method to perform the same thing plus aggregation.""" - self.cutoff_menu.slider_change.connect(self.update_tab) - self.switches.currentIndexChanged.connect(self.toggle_comparisons) - self.combobox_menu.method.currentIndexChanged.connect(self.update_tab) - self.combobox_menu.func.currentIndexChanged.connect(self.update_tab) - self.combobox_menu.agg.currentIndexChanged.connect(self.update_tab) - self.combobox_menu.scenario.currentIndexChanged.connect(self.update_tab) - - def update_tab(self): - """Update the tab.""" - QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) - self.set_combobox_changes() - - super().update_tab() - QtWidgets.QApplication.restoreOverrideCursor() - - def update_dataframe(self, *args, **kwargs): - """Update the underlying dataframe. - - Implement in subclass.""" - raise NotImplementedError - - def update_table(self): - super().update_table(self.df, unit=self.unit) - - def update_plot(self): - """Update the plot.""" - idx = self.pt_layout.indexOf(self.plot) - self.plot.figure.clf() - # name is already altered by set_filename before update_plot occurs. - name = self.plot.plot_name - self.plot.setVisible(False) - self.plot.deleteLater() - self.plot = ContributionPlot(self) - self.pt_layout.insertWidget(idx, self.plot) - super().update_plot(self.df, unit=self.unit) - self.plot.plot_name = name - if self.pt_layout.parentWidget(): - self.pt_layout.parentWidget().updateGeometry() - - -class ElementaryFlowContributionTab(ContributionTab): - """Class for the 'Elementary flow Contributions' sub-tab. - - This tab allows for analysis of elementary flows. - - Example questions that can be answered by this tab: - What is the CO2 production caused by reference flow XXX? - Which impact is largest on the impact category YYY? - What are the 5 largest elementary flows caused by reference flow ZZZ? - - Shows: - Cutoff menu for determining cutoff values - Compare options button to change between 'Reference Flows' and 'Impact Categories' - 'Impact Category'/'Reference Flow' chooser with aggregation method - Plot/Table on/off and Relative/Absolute options for data - Plot/Table - Export options - """ - - def __init__(self, parent=None): - super().__init__(parent) - - header = get_header_layout_w_help("Elementary Flow Contributions", self.help_button) - self.layout.addLayout(header) - self.layout.addWidget(self.cutoff_menu) - self.layout.addWidget(horizontal_line()) - combobox = self.build_combobox(has_method=True, has_func=True) - self.layout.addLayout(combobox) - self.layout.addWidget(horizontal_line()) - self.layout.addWidget(self.build_main_space()) - self.layout.addLayout(self.build_export(True, True)) - - self.contribution_fn = "EF contributions" - self.switches.configure(self.has_func, self.has_method) - self.connect_signals() - self.toggle_comparisons(self.switches.indexes.func) - - def build_combobox( - self, has_method: bool = True, has_func: bool = False - ) -> QtWidgets.QHBoxLayout: - self.combobox_menu.agg.addItems(self.parent.contributions.DEFAULT_EF_AGGREGATES) - return super().build_combobox(has_method, has_func) - - def update_dataframe(self, *args, **kwargs): - """Retrieve the top elementary flow contributions.""" - return self.parent.contributions.top_elementary_flow_contributions( - **kwargs, - limit=self.cutoff_menu.cutoff_value, - limit_type=self.cutoff_menu.limit_type, - normalize=self.relative, - total_range=self.total_range, - ) - - -class ProcessContributionsTab(ContributionTab): - """Class for the 'Process Contributions' sub-tab. - - This tab allows for analysis of process contributions. - - Example questions that can be answered by this tab: - What is the contribution of electricity production to reference flow XXX? - Which process contributes the most to impact category YYY? - What are the top 5 contributing processes to reference flow ZZZ? - - Shows: - Cutoff menu for determining cutoff values - Compare options button to change between 'Reference Flows' and 'Impact Categories' - 'Impact Category'/'Reference Flow' chooser with aggregation method - Plot/Table on/off and Relative/Absolute options for data - Plot/Table - Export options - """ - - def __init__(self, parent=None): - super().__init__(parent) - - header = get_header_layout_w_help("Process Contributions", self.help_button) - self.layout.addLayout(header) - self.layout.addWidget(self.cutoff_menu) - self.layout.addWidget(horizontal_line()) - combobox = self.build_combobox(has_method=True, has_func=True) - self.layout.addLayout(combobox) - self.layout.addWidget(horizontal_line()) - self.layout.addWidget(self.build_main_space()) - self.layout.addLayout(self.build_export(True, True)) - - self.contribution_fn = "Process contributions" - self.switches.configure(self.has_func, self.has_method) - self.connect_signals() - self.toggle_comparisons(self.switches.indexes.func) - - def build_combobox( - self, has_method: bool = True, has_func: bool = False - ) -> QtWidgets.QHBoxLayout: - self.combobox_menu.agg.addItems( - self.parent.contributions.DEFAULT_ACT_AGGREGATES - ) - return super().build_combobox(has_method, has_func) - - def update_dataframe(self, *args, **kwargs): - """Retrieve the top process contributions""" - return self.parent.contributions.top_process_contributions( - **kwargs, - limit=self.cutoff_menu.cutoff_value, - limit_type=self.cutoff_menu.limit_type, - normalize=self.relative, - total_range=self.total_range, - ) - - -class FirstTierContributionsTab(ContributionTab): - """Class for the 'First Tier Contributions' sub-tab. - - This tab allows for analysis of first-tier (product) contributions. - The direct impact (from biosphere exchanges from the FU) - and cumulative impacts from all exchange inputs to the FU (first level) are calculated. - - e.g. the direct emissions from steel production and the cumulative impact for all electricity input - into that activity. This works on the basis of input products and their total (cumulative) impact, scaled to - how much of that product is needed in the FU. - - Example questions that can be answered by this tab: - What is the contribution of electricity (product) to reference flow XXX? - Which input product contributes the most to impact category YYY? - What products contribute most to reference flow ZZZ? - - Shows: - Compare options button to change between 'Reference Flows' and 'Impact Categories' - 'Impact Category'/'Reference Flow' chooser with aggregation method - Plot/Table on/off and Relative/Absolute options for data - Plot/Table - Export options - """ - - def __init__(self, cs_name, parent=None): - super().__init__(parent) - - self.cache = {"scores": {}, "ranges": {}} # We cache the calculated data, as it can take some time to generate. - # We cache the individual calculation results, as they are re-used in multiple views - # e.g. FU1 x method1 x scenario1 - # may be seen in both 'Reference Flows' and 'Impact Categories', just with different axes. - # we also cache scores/ranges, not for calculation speed, but to be able to easily convert for relative results - self.caching = True # set to False to disable caching for debug - - header = get_header_layout_w_help("First Tier Contributions", self.help_button) - self.layout.addLayout(header) - self.layout.addWidget(self.cutoff_menu) - self.layout.addWidget(horizontal_line()) - combobox = self.build_combobox(has_method=True, has_func=True) - self.layout.addLayout(combobox) - self.layout.addWidget(horizontal_line()) - self.layout.addWidget(self.build_main_space()) - self.layout.addLayout(self.build_export(True, True)) - - # get relevant data from calculation setup - self.cs = cs_name - func_units = bd.calculation_setups[self.cs]["inv"] - self.func_keys = [list(fu.keys())[0] for fu in func_units] # extract a list of keys from the functional units - self.func_units = [ - {bd.get_activity(k): v for k, v in fu.items()} - for fu in func_units - ] - self.methods = bd.calculation_setups[self.cs]["ia"] - - self.contribution_fn = "First Tier contributions" - self.switches.configure(self.has_func, self.has_method) - self.connect_signals() - self.toggle_comparisons(self.switches.indexes.func) - - def update_tab(self): - """Update the tab.""" - if self.has_been_opened: - super().update_tab() - - def build_combobox( - self, has_method: bool = True, has_func: bool = False - ) -> QtWidgets.QHBoxLayout: - self.combobox_menu.agg.addItems( - self.parent.contributions.DEFAULT_ACT_AGGREGATES - ) - return super().build_combobox(has_method, has_func) - - def get_data(self, compare) -> List[list]: - """Get the data for analysis, either from self.cache or from calculation.""" - def try_cache(): - """Get data from cache if exists, otherwise return none.""" - if self.caching: - return self.cache.get(cache_key, None) - - def calculate(): - """Shorthand for getting calculation results.""" - return self.calculate_contributions(demand, demand_key, demand_index, - method=method, method_index=method_index, - scenario_lca=self.has_scenarios, scenario_index=scenario_index, - ) - - # get the right data - if self.has_scenarios: - # get the scenario index, if it is -1 (none selected), then use index first index (0) - scenario_index = max(self.combobox_menu.scenario.currentIndex(), 0) - else: - scenario_index = None - method_index = self.combobox_menu.method.currentIndex() - method = self.methods[method_index] - demand_index = self.combobox_menu.func.currentIndex() - demand = self.func_units[demand_index] - demand_key = self.func_keys[demand_index] - - all_data = [] - if compare == "Reference Flows": - # run the analysis for every reference flow - for demand_index, demand in enumerate(self.func_units): - demand_key = self.func_keys[demand_index] - cache_key = (demand_index, method_index, scenario_index) - # get data from cache if exists, otherwise calculate - if data := try_cache(): - all_data.append([demand_key, data]) - continue - - data = calculate() - if self.caching: - self.cache[cache_key] = data - all_data.append([demand_key, data]) - elif compare == "Impact Categories": - # run the analysis for every method - for method_index, method in enumerate(self.methods): - cache_key = (demand_index, method_index, scenario_index) - - # get data from cache if exists, otherwise calculate - if data := try_cache(): - all_data.append([method, data]) - continue - - data = calculate() - if self.caching: - self.cache[cache_key] = data - all_data.append([method, data]) - elif compare == "Scenarios": - # run the analysis for every scenario - for scenario_index in range(self.combobox_menu.scenario.count()): - scenario = self.combobox_menu.scenario.itemText(scenario_index) - cache_key = (demand_index, method_index, scenario_index) - - # get data from cache if exists, otherwise calculate - if data := try_cache(): - all_data.append([scenario, data]) - continue - - data = calculate() - if self.caching: - self.cache[cache_key] = data - all_data.append([scenario, data]) - - return all_data - - def calculate_contributions(self, demand, demand_key, demand_index, - method, method_index: int = None, - scenario_lca: bool = False, scenario_index: int = None) -> dict: - """Retrieve relevant activity data and calculate first tier contributions.""" - - def get_default_demands() -> dict: - """Get the inputs to calculate contributions from the activity""" - # get exchange keys leading to this activity - technosphere = bd.get_activity(demand_key).technosphere() - - keys = [exch.input.key for exch in technosphere if - exch.input.key != exch.output.key] - # find scale from production amount and demand amount - scale = demand[demand_key] / [p for p in bd.get_activity(demand_key).production()][0].amount - - amounts = [exch.amount * scale for exch in technosphere if - exch.input.key != exch.output.key] - demands = {keys[i]: amounts[i] for i, _ in enumerate(keys)} - return demands - - def get_scenario_demands() -> dict: - """Get the inputs to calculate contributions from the scenario matrix""" - # get exchange keys leading to this activity - technosphere = bd.get_activity(demand_key).technosphere() - demand_idx = _lca.product_dict[demand_key] - - keys = [exch.input.key for exch in technosphere if - exch.input.key != exch.output.key] - # find scale from production amount and demand amount - scale = demand[demand_key] / _lca.technosphere_matrix[_lca.activity_dict[demand_key], demand_idx] * -1 - - amounts = [] - - for exch in technosphere: - exch_idx = _lca.activity_dict[exch.input.key] - if exch.input.key != exch.output.key: - amounts.append(_lca.technosphere_matrix[exch_idx, demand_idx] * scale) - - # write al non-zero exchanges to demand dict - demands = {keys[i]: amounts[i] for i, _ in enumerate(keys) if amounts[i] != 0} - return demands - - # reuse LCA object from original calculation to skip 1 LCA - if scenario_lca: - # get score from the already calculated result - score = self.parent.mlca.lca_scores[demand_index, method_index, scenario_index] - - # get lca object from mlca class - self.parent.mlca.current = scenario_index - self.parent.mlca.update_matrices() - _lca = self.parent.mlca.lca - _lca.redo_lci(demand) - - else: - # get score from the already calculated result - score = self.parent.mlca.lca_scores[demand_index, method_index] - - # get lca object to calculate new results - _lca = self.parent.mlca.lca - - # set the correct method - _lca.switch_method(method) - _lca.lcia_calculation() - - if score == 0: - # no need to calculate contributions to '0' score - # technically it could be that positive and negative score of same amount negate to 0, but highly unlikely. - return {"Score": 0, "Range": 0, demand_key: 0} - - data = {"Score": score} - _range = [] - remainder = score # contribution of demand_key - - if not scenario_lca: - new_demands = get_default_demands() - else: - new_demands = get_scenario_demands() - - # iterate over all activities demand_key is connected to - for key, amt in new_demands.items(): - - # recalculate for this demand - _lca.redo_lci({key: amt}) - _lca.redo_lcia() - - score = _lca.score - if score != 0: - # only store non-zero results - data[key] = score - _range.append(abs(score)) - remainder -= score # subtract this from remainder - - data[demand_key] = remainder - _range.append(abs(remainder)) - data["Range"] = sum(_range) - return data - - def key_to_metadata(self, key: tuple) -> list: - """Convert the key information to list with metadata. - - format: - [reference product, activity name, location, unit, database] - """ - return list(app.metadata.get_metadata([key], ["reference product", "name", "location", "unit"]).iloc[0]) + [key[0]] - - def metadata_to_index(self, data: list) -> str: - """Convert list to formatted index. - - format: - reference product | activity name | location | unit | database - """ - return " | ".join(data) - - def data_to_df(self, all_data: List[list], compare: str) -> pd.DataFrame: - """Convert the provided data into a dataframe.""" - unique_keys = set() - # get all the unique keys: - d = {"index": [], "reference product": [], "name": [], - "location": [], "unit": [], "database": []} - meta_cols = set(d.keys()) - - for i, (item, data) in enumerate(all_data): - # item is a key, method or scenario depending on the `compares` - unique_keys.update(data.keys()) - # already add the total with right column formatting depending on `compares` - if compare == "Reference Flows": - col_name = self.metadata_to_index(self.key_to_metadata(item)) - elif compare == "Impact Categories": - col_name = self.metadata_to_index(list(item)) - elif compare == "Scenarios": - col_name = item - - self.cache["scores"][col_name] = data["Score"] - self.cache["ranges"][col_name] = data["Range"] - d[col_name] = [] - - all_data[i] = item, data, col_name - - if compare == "Impact Categories": - self.unit = get_unit(method=False, relative=self.relative) - else: - self.unit = get_unit(self.parent.method_dict[self.combobox_menu.method.currentText()], self.relative) - - # convert to dict format to feed into dataframe - for key in unique_keys: - if key in ["Score", "Range"]: - continue - # get metadata - metadata = self.key_to_metadata(key) - d["index"].append(self.metadata_to_index(metadata)) - d["reference product"].append(metadata[0]) - d["name"].append(metadata[1]) - d["location"].append(metadata[2]) - d["unit"].append(self.unit) - d["database"].append(metadata[4]) - # check for each dataset if we have values, otherwise add np.nan - for item, data, col_name in all_data: - if val := data.get(key, False): - value = val - else: - value = np.nan - d[col_name].append(value) - - df = pd.DataFrame(d) - data_cols = [col for col in df if col not in meta_cols] - df = df.dropna(subset=data_cols, how="all") - - # now, apply aggregation - group_on = self.combobox_menu.agg.currentText() - if group_on != "none": - df = df.groupby(by=group_on, as_index=False).sum() - df["index"] = df[group_on] - df = df[["index"] + data_cols] - meta_cols = ["index"] - - all_contributions = deepcopy(df) - - # now, apply cut-off - limit_type = self.cutoff_menu.limit_type - limit = self.cutoff_menu.cutoff_value - - # iterate over the columns to get contributors, then replace cutoff flows with nan - # nested for is slow, but this should rarely have to deal with >>50 rows (rows == technosphere exchanges) - contributors = df[data_cols].shape[0] - for col_num, col in enumerate(df[data_cols].T.values): - # now, get total: - if self.total_range: # total is based on the range - total = np.nansum(np.abs(col)) - else: # total is based on the score - total = np.nansum(col) - - col = np.nan_to_num(col) # replace nan with 0 - cont = ca.sort_array(col, limit=limit, limit_type=limit_type, total=total) - # write nans to values not present in cont - for row_num in range(contributors): - if row_num not in cont[:, 1]: - df.iloc[row_num, col_num + len(meta_cols)] = np.nan - - # drop any rows not contributing to anything - df = df.dropna(subset=data_cols, how="all") - - # sort by mean square of each row - func = lambda row: np.nanmean(np.square(row)) - if len(df) > 1: # but only sort if there is something to sort - df["_sort_me_"] = df[data_cols].apply(func, axis=1) - df.sort_values(by="_sort_me_", ascending=False, inplace=True) - del df["_sort_me_"] - - # add the scores and rest values - score_and_rest = {col: [] for col in df} - for col in df: - if col == "index": - score_and_rest[col].extend(["Score", "Rest (+)", "Rest (-)"]) - elif col in data_cols: - # score - score = self.cache["scores"][col] - # positive and negative rest values - pos_rest = (np.sum((all_contributions[col].values)[all_contributions[col].values > 0]) - - np.sum((df[col].values)[df[col].values > 0])) - neg_rest = (np.sum((all_contributions[col].values)[all_contributions[col].values < 0]) - - np.sum((df[col].values)[df[col].values < 0])) - - score_and_rest[col].extend([score, pos_rest, neg_rest]) - else: - score_and_rest[col].extend(["", "", ""]) - - # add the two df together - df = pd.concat([pd.DataFrame(score_and_rest), df], axis=0) - - # normalize - if self.relative: - if self.total_range: - normalize = [self.cache["ranges"][col] for col in data_cols] - else: - normalize = [self.cache["scores"][col] for col in data_cols] - df[data_cols] = df[data_cols] / normalize - - return df - - def update_dataframe(self, *args, **kwargs): - """Retrieve the product contributions.""" - - compare = self.switches.currentText() - - all_data = self.get_data(compare) - df = self.data_to_df(all_data, compare) - return df - - -class CorrelationsTab(NewAnalysisTab): - def __init__(self, parent): - super().__init__(parent) - self.parent = parent - - self.tab_text = "Correlations" - self.layout.addLayout(get_header_layout("Correlation Analysis")) - - if not self.parent.single_func_unit: - self.plot = CorrelationPlot(self.parent) - - self.layout.addWidget(self.build_main_space()) - self.layout.addLayout( - self.build_export( - has_table=False, has_plot=not self.parent.single_func_unit - ) - ) - - def update_plot(self): - """Update the plot.""" - idx = self.pt_layout.indexOf(self.plot) - self.plot.figure.clf() - self.plot.setVisible(False) - self.plot.deleteLater() - self.plot = CorrelationPlot(self.parent) - self.pt_layout.insertWidget(idx, self.plot) - df = self.parent.mlca.get_normalized_scores_df() - super().update_plot(df) - if self.pt_layout.parentWidget(): - self.pt_layout.parentWidget().updateGeometry() - - -class MonteCarloTab(NewAnalysisTab): - def __init__(self, parent=None): - super(MonteCarloTab, self).__init__(parent) - self.parent: LCAResultsSubTab = parent - header_ = QtWidgets.QToolBar() - _header = header("Monte Carlo Simulation") - _header.setToolTip("Left click on the question mark for help") - header_.addWidget(_header) - header_.addAction( - icons.qicons.question, - "Left click for help on Monte Carlo analysis", - self.explanation, - ) - self.layout.addWidget(header_) - self.scenario_label = QtWidgets.QLabel("Scenario:") - self.include_box = QtWidgets.QGroupBox("Include uncertainty for:", self) - grid = QtWidgets.QGridLayout() - self.include_tech = QtWidgets.QCheckBox("Technosphere", self) - self.include_tech.setChecked(True) - self.include_bio = QtWidgets.QCheckBox("Biosphere", self) - self.include_bio.setChecked(True) - self.include_cf = QtWidgets.QCheckBox("Characterization Factors", self) - self.include_cf.setChecked(False) - self.include_cf.setEnabled(False) - self.include_parameters = QtWidgets.QCheckBox("Parameters", self) - self.include_parameters.setChecked(False) - self.include_parameters.setEnabled(False) - grid.addWidget(self.include_tech, 0, 0) - grid.addWidget(self.include_bio, 0, 1) - grid.addWidget(self.include_cf, 1, 0) - grid.addWidget(self.include_parameters, 1, 1) - self.include_box.setLayout(grid) - - self.add_MC_ui_elements() - - self.table = LCAResultsTable() - self.table.table_name = "MonteCarlo_" + self.parent.cs_name - self.plot = MonteCarloPlot(self.parent) - self.plot.hide() - self.plot.plot_name = "MonteCarlo_" + self.parent.cs_name - self.layout.addWidget(self.plot) - self.export_widget = self.build_export(has_plot=True, has_table=True) - self.layout.addWidget(self.export_widget) - self.layout.setAlignment(QtCore.Qt.AlignTop) - self.connect_signals() - self.explain_text = """ -

Monte Carlo Analyses

-

Monte Carlo simulations generate stochastic data samples using existing data defined parameter - distributions for generating the expected distribution for the reference flows.

-

More simply, within the LCA model the user may define certain uncertainty distributions for some - (or all) parameters. Monte Carlo analysis uses these defined uncertainty distributions with a stochastic - generator to sample from these distributions. This results in a "posterior" (or final) probability - distribution, expressing the expected variance, for the reference flows.

-

More - information can be found here

- """ - - def connect_signals(self): - self.button_run.clicked.connect(self.calculate_mc_lca) - # signals.monte_carlo_ready.connect(self.update_mc) - # self.combobox_fu.currentIndexChanged.connect(self.update_plot) - self.combobox_methods.currentIndexChanged.connect( - # ignore the index and send the cs_name instead - lambda x: self.update_mc(cs_name=self.parent.cs_name) - ) - - # signals - # self.radio_button_biosphere.clicked.connect(self.button_clicked) - # self.radio_button_technosphere.clicked.connect(self.button_clicked) - - if self.has_scenarios: - self.scenario_box.currentIndexChanged.connect( - self.parent.update_scenario_data - ) - self.parent.update_scenario_box_index.connect( - lambda index: self.set_combobox_index(self.scenario_box, index) - ) - - def add_MC_ui_elements(self): - layout_mc = QtWidgets.QVBoxLayout() - - # H-LAYOUT start simulation - self.button_run = QtWidgets.QPushButton("Run") - self.label_iterations = QtWidgets.QLabel("Iterations:") - self.iterations = QtWidgets.QLineEdit("30") - self.iterations.setFixedWidth(40) - self.iterations.setValidator(QtGui.QIntValidator(1, 1000)) - self.label_seed = QtWidgets.QLabel("Random seed:") - self.label_seed.setToolTip( - "Seed value (integer) for the random number generator. " - "Use this for reproducible samples." - ) - self.seed = QtWidgets.QLineEdit("") - self.seed.setFixedWidth(30) - - self.hlayout_run = QtWidgets.QHBoxLayout() - self.hlayout_run.addWidget(self.scenario_label) - self.hlayout_run.addWidget(self.scenario_box) - self.hlayout_run.addWidget(self.button_run) - self.hlayout_run.addWidget(self.label_iterations) - self.hlayout_run.addWidget(self.iterations) - self.hlayout_run.addWidget(self.label_seed) - self.hlayout_run.addWidget(self.seed) - self.hlayout_run.addWidget(self.include_box) - self.hlayout_run.addStretch(1) - layout_mc.addLayout(self.hlayout_run) - - # self.label_running = QLabel('Running a Monte Carlo simulation. Please allow some time for this. ' - # 'Please do not run another simulation at the same time.') - # self.layout_mc.addWidget(self.label_running) - # self.label_running.hide() - - # # buttons for all FUs or for all methods - # self.radio_button_all_fu = QRadioButton("For all reference flows") - # self.radio_button_all_methods = QRadioButton("Technosphere flows") - # - # self.radio_button_biosphere.setChecked(True) - # self.radio_button_technosphere.setChecked(False) - # - # self.label_for_all_fu = QLabel('For all reference flows') - # self.combobox_fu = QRadioButton() - # self.hlayout_fu = QHBoxLayout() - - # FU selection - # self.label_fu = QLabel('Choose reference flow') - # self.combobox_fu = QComboBox() - # self.hlayout_fu = QHBoxLayout() - # - # self.hlayout_fu.addWidget(self.label_fu) - # self.hlayout_fu.addWidget(self.combobox_fu) - # self.hlayout_fu.addStretch() - # self.layout_mc.addLayout(self.hlayout_fu) - - # method selection - self.method_selection_widget = QtWidgets.QWidget() - self.label_methods = QtWidgets.QLabel("Choose impact category") - self.combobox_methods = QtWidgets.QComboBox() - self.hlayout_methods = QtWidgets.QHBoxLayout() - - self.hlayout_methods.addWidget(self.label_methods) - self.hlayout_methods.addWidget(self.combobox_methods) - self.hlayout_methods.addStretch() - self.method_selection_widget.setLayout(self.hlayout_methods) - - layout_mc.addWidget(self.method_selection_widget) - self.method_selection_widget.hide() - - self.layout.addLayout(layout_mc) - - def build_export(self, has_table: bool = True, has_plot: bool = True) -> QtWidgets.QWidget: - """Construct the export layout but set it into a widget because we - want to hide it.""" - export_layout = super().build_export(has_table, has_plot) - export_widget = QtWidgets.QWidget() - export_widget.setLayout(export_layout) - # Hide widget until MC is calculated - export_widget.hide() - return export_widget - - @QtCore.Slot(name="calculateMcLca") - def calculate_mc_lca(self): - self.method_selection_widget.hide() - self.plot.hide() - self.export_widget.hide() - - iterations = int(self.iterations.text()) - seed = None - if self.seed.text(): - logger.info(f"SEED: {self.seed.text()}") - try: - seed = int(self.seed.text()) - except ValueError as e: - logger.error( - "Seed value must be an integer number or left empty.", exc_info=e - ) - QtWidgets.QMessageBox.warning( - self, - "Warning", - "Seed value must be an integer number or left empty.", - ) - self.seed.setText("") - return - includes = { - "technosphere": self.include_tech.isChecked(), - "biosphere": self.include_bio.isChecked(), - "cf": self.include_cf.isChecked(), - "parameters": self.include_parameters.isChecked(), - } - - QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) - try: - self.parent.mc.calculate(iterations=iterations, seed=seed, **includes) - app.signals.monte_carlo_finished.emit() - self.update_mc() - except ( - InvalidParamsError - ) as e: # This can occur if uncertainty data is missing or otherwise broken - # print(e) - logger.error(e) - QtWidgets.QMessageBox.warning( - self, "Could not perform Monte Carlo simulation", str(e) - ) - QtWidgets.QApplication.restoreOverrideCursor() - - # a threaded way for this - unfortunatley this crashes as: - # pypardsio_solver is used for the 'spsolve' and 'factorized' functions. Python crashes on windows if multiple - # instances of PyPardisoSolver make calls to the Pardiso library - # worker_thread = WorkerThread() - # print('Created local worker_thread') - # worker_thread.set_mc(self.parent.mc, iterations=iterations) - # print('Passed object to thread.') - # worker_thread.start() - # self.label_running.show() - - # - - # thread = NewCSMCThread() #self.parent.mc - # thread.calculation_finished.connect( - # lambda x: print('Calculation finished.')) - # thread.start() - - # # give us a thread and start it - # thread = QtCore.QThread() - # thread.start() - # - # # create a worker and move it to our extra thread - # worker = Worker() - # worker.moveToThread(thread) - - # self.parent.mct.start() - # self.parent.mct.run(iterations=iterations) - # self.parent.mct.finished() - - # objThread = QtCore.QThread() - # obj = QObjectMC() # self.parent.cs_name - # obj.moveToThread(objThread) - # obj.finished.connect(objThread.quit) - # objThread.started.connect(obj.long_running) - # # objThread.finished.connect(app.exit) - # objThread.finished.connect( - # lambda x: print('Finished Thread!') - # ) - # objThread.start() - - # objThread = QtCore.QThread() - # obj = SomeObject() - # obj.moveToThread(objThread) - # obj.finished.connect(objThread.quit) - # objThread.started.connect(obj.long_running) - # objThread.finished.connect( - # lambda x: print('Finished Thread!') - # ) - # objThread.start() - - # self.method_selection_widget.show() - # self.plot.show() - # self.export_widget.show() - - def configure_scenario(self): - super().configure_scenario() - self.scenario_label.setVisible(self.has_scenarios) - - def update_tab(self): - self.update_combobox( - self.combobox_methods, [str(m) for m in self.parent.mc.methods] - ) - # self.update_combobox(self.combobox_methods, [str(m) for m in self.parent.mct.mc.methods]) - - def update_mc(self, cs_name=None): - # act = self.combobox_fu.currentText() - # activity_index = self.combobox_fu.currentIndex() - # act_key = self.parent.mc.activity_keys[activity_index] - # if cs_name != self.parent.cs_name: # relevant if several CS are open at the same time - # return - - # self.label_running.hide() - self.method_selection_widget.show() - self.export_widget.show() - - method_index = self.combobox_methods.currentIndex() - method = self.parent.mc.methods[method_index] - - # data = self.parent.mc.get_results_by(act_key=act_key, method=method) - self.df = self.parent.mc.get_results_dataframe(method=method) - - self.update_table() - self.update_plot(method=method) - filename = "_".join( - [str(x) for x in [self.parent.cs_name, "Monte Carlo results", str(method)]] - ) - self.plot.plot_name, self.table.table_name = filename, filename - - def update_plot(self, method): - idx = self.layout.indexOf(self.plot) - self.plot.figure.clf() - self.plot.setVisible(False) - self.plot.deleteLater() - # name is already altered by update_mc before update_plot - name = self.plot.plot_name - self.plot = MonteCarloPlot(self.parent) - self.layout.insertWidget(idx, self.plot) - super().update_plot(self.df, method=method) - self.plot.plot_name = name - self.plot.show() - if self.layout.parentWidget(): - self.layout.parentWidget().updateGeometry() - - def update_table(self): - super().update_table(self.df) - - -class GSATab(NewAnalysisTab): - def __init__(self, parent=None): - super(GSATab, self).__init__(parent) - self.parent = parent - - self.GSA = GlobalSensitivityAnalysis(self.parent.mc) - - header_ = QtWidgets.QToolBar() - _header = header("Global Sensitivity Analysis") - _header.setToolTip("Left click on the question mark for help") - header_.addWidget(_header) - header_.addAction( - icons.qicons.question, - "Left click for help on Global Sensitivity Analysis", - self.explanation, - ) - - self.layout.addWidget(header_) - self.scenario_box = None - - self.add_GSA_ui_elements() - - self.table = LCAResultsTable() - self.table.table_name = "GSA_" + self.parent.cs_name - self.layout.addWidget(self.table) - self.table.hide() - # self.plot = MonteCarloPlot(self.parent) - # self.plot.hide() - # self.plot.plot_name = 'GSA_' + self.parent.cs_name - # self.layout.addWidget(self.plot) - - self.export_widget = self.build_export(has_plot=False, has_table=True) - self.layout.addWidget(self.export_widget) - self.layout.setAlignment(QtCore.Qt.AlignTop) - self.connect_signals() - - self.explain_text = """ -

Global Sensitivity Analysis (GSA) is a family of methods that, used in conjunction with distribution - generating functions, can investigate the contributions of model variables on the final results.

-

Within the AB running a GSA depends on the use of a Monte Carlo simulation for generating the - variable distributions for the reference flow(s), upon which the GSA is performed. Running the GSA executes - the stochastic simulations whilst fixing the values of selected variables of interest. Taking a lower and - upper bound for the variables, therefore, indicates the influence of the fixed variable on the overall - level of model variability.

-

For a more detailed explanation see the wiki

-

The paper describing the methods is published by Wiley online

- """ - - def connect_signals(self): - self.button_run.clicked.connect(self.calculate_gsa) - app.signals.monte_carlo_finished.connect(self.monte_carlo_finished) - - def add_GSA_ui_elements(self): - # H-LAYOUT SETTINGS ROW 1 - - # run button - self.button_run = QtWidgets.QPushButton("Run") - self.button_run.setEnabled(False) - - # reference flow selection - self.label_fu = QtWidgets.QLabel("Reference Flow:") - self.combobox_fu = QtWidgets.QComboBox() - - # method selection - self.label_methods = QtWidgets.QLabel("Impact Category:") - self.combobox_methods = QtWidgets.QComboBox() - - # arrange layout - self.hlayout_row1 = QtWidgets.QHBoxLayout() - self.hlayout_row1.addWidget(self.button_run) - self.hlayout_row1.addWidget(self.label_fu) - self.hlayout_row1.addWidget(self.combobox_fu) - self.hlayout_row1.addWidget(self.label_methods) - self.hlayout_row1.addWidget(self.combobox_methods) - - # self.hlayout_row1.addWidget(self.fu_selection_widget) - # self.hlayout_row1.addWidget(self.method_selection_widget) - self.hlayout_row1.addStretch(1) - - # H-LAYOUT SETTINGS ROW 2 - self.hlayout_row2 = QtWidgets.QHBoxLayout() - - # cutoff technosphere - self.label_cutoff_technosphere = QtWidgets.QLabel("Cut-off technosphere:") - self.cutoff_technosphere = QtWidgets.QLineEdit("0.01") - self.cutoff_technosphere.setFixedWidth(40) - self.cutoff_technosphere.setValidator(QtGui.QDoubleValidator(0.0, 1.0, 5)) - - # cutoff biosphere - self.label_cutoff_biosphere = QtWidgets.QLabel("Cut-off biosphere:") - self.cutoff_biosphere = QtWidgets.QLineEdit("0.01") - self.cutoff_biosphere.setFixedWidth(40) - self.cutoff_biosphere.setValidator(QtGui.QDoubleValidator(0.0, 1.0, 5)) - - # export GSA input/output data automatically with run - self.checkbox_export_data_automatically = QtWidgets.QCheckBox( - "Save input/output data to Excel after run" - ) - self.checkbox_export_data_automatically.setChecked(False) - - # # exclude Pedigree - # self.checkbox_pedigree = QCheckBox('Include Pedigree uncertainties') - # self.checkbox_pedigree.setChecked(True) - - # arrange layout - self.hlayout_row2.addWidget(self.label_cutoff_technosphere) - self.hlayout_row2.addWidget(self.cutoff_technosphere) - self.hlayout_row2.addWidget(self.label_cutoff_biosphere) - self.hlayout_row2.addWidget(self.cutoff_biosphere) - self.hlayout_row2.addWidget(self.checkbox_export_data_automatically) - # self.hlayout_row2.addWidget(self.checkbox_pedigree) - self.hlayout_row2.addStretch(1) - - # OVERALL LAYOUT OF SETTINGS - self.layout_settings = QtWidgets.QVBoxLayout() - self.layout_settings.addLayout(self.hlayout_row1) - self.layout_settings.addLayout(self.hlayout_row2) - self.widget_settings = QtWidgets.QWidget() - self.widget_settings.setLayout(self.layout_settings) - - # add to GSA layout - self.label_monte_carlo_first = QtWidgets.QLabel( - "You need to run a Monte Carlo Simulation first." - ) - self.layout.addWidget(self.label_monte_carlo_first) - self.layout.addWidget(self.widget_settings) - - # at start - # todo: this is just for development, should be reversed later: - self.widget_settings.hide() - # self.label_monte_carlo_first.hide() - - def update_tab(self): - self.update_combobox( - self.combobox_methods, [str(m) for m in self.parent.mc.methods] - ) - self.update_combobox( - self.combobox_fu, list(self.parent.mlca.func_unit_translation_dict.keys()) - ) - - def monte_carlo_finished(self): - self.button_run.setEnabled(True) - self.widget_settings.show() - self.label_monte_carlo_first.hide() - - def calculate_gsa(self): - act_number = self.combobox_fu.currentIndex() - method_number = self.combobox_methods.currentIndex() - cutoff_technosphere = float(self.cutoff_technosphere.text()) - cutoff_biosphere = float(self.cutoff_biosphere.text()) - # print('Calculating GSA for: ', act_number, method_number, cutoff_technosphere, cutoff_biosphere) - - try: - QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) - self.GSA.perform_GSA( - act_number=act_number, - method_number=method_number, - cutoff_technosphere=cutoff_technosphere, - cutoff_biosphere=cutoff_biosphere, - ) - # self.update_mc() - except Exception as e: - import traceback - traceback.print_tb(e.__traceback__) - logger.error(e) - message = str(e) - message_addition = "" - if message == "singular matrix": - message_addition = "\nIn order to avoid this happening, please increase the Monte Carlo iterations (e.g. to above 50)." - elif message == "`dataset` input should have multiple elements.": - message_addition = "\nIn order to avoid this happening, please increase the Monte Carlo iterations (e.g. to above 50)." - elif message == "No objects to concatenate": - message_addition = ( - "\nThe reason for this is likely that there are no uncertain exchanges. Please check " - "the checkboxes in the Monte Carlo tab." - ) - QtWidgets.QMessageBox.warning( - self, "Could not perform GSA", str(message) + message_addition - ) - QtWidgets.QApplication.restoreOverrideCursor() - - self.update_gsa() - - def update_gsa(self, cs_name=None): - self.df = getattr(self.GSA, "df_final", None) - if self.df is None: - return - self.update_table() - self.table.show() - self.export_widget.show() - - self.table.table_name = "gsa_output_" + self.GSA.get_save_name() - - if self.checkbox_export_data_automatically.isChecked(): - logger.info("EXPORTING DATA") - self.GSA.export_GSA_input() - self.GSA.export_GSA_output() - - def update_plot(self, method): - pass - - def update_table(self): - super().update_table(self.df) - - def build_export(self, has_table: bool = True, has_plot: bool = True) -> QtWidgets.QWidget: - """Construct the export layout but set it into a widget because we - want to hide it.""" - export_layout = super().build_export(has_table, has_plot) - export_widget = QtWidgets.QWidget() - export_widget.setLayout(export_layout) - # Hide widget until MC is calculated - export_widget.hide() - return export_widget - - # TODO review if can be removed - # def set_filename(self, optional_fields: dict = None): - # """Given a dictionary of fields, put together a usable filename for the plot and table.""" - # save_name = 'gsa_output_' + self.mc.cs_name + '_' + str(self.mc.iterations) + '_' + self.activity['name'] + \ - # '_' + str(self.method) + '.xlsx' - # save_name = save_name.replace(',', '').replace("'", '').replace("/", '') - # self.table.table_name = save_name - # optional = optional_fields or {} - # fields = ( - # self.parent.cs_name, self.contribution_fn, optional.get("method"), - # optional.get("functional_unit"), self.unit - # ) - # filename = '_'.join((str(x) for x in fields if x is not None)) - - -class MonteCarloWorkerThread(QtCore.QThread): - """A worker for Monte Carlo simulations. - - Unfortunately, pyparadiso does not allow parallel calculations on Windows (crashes). - So this is for future reference in case this issue is solved...""" - - def __init__(self): - pass - - def set_mc(self, mc, iterations=20): - self.mc = mc - self.iterations = iterations - - def run(self): - logger.info(f"Starting new Worker Thread. Iterations: {self.iterations}") - self.mc.calculate(iterations=self.iterations) - # res = bw.GraphTraversal().calculate(self.demand, self.method, self.cutoff, self.max_calc) - logger.info("in thread {}".format(QtCore.QThread.currentThread())) - app.signals.monte_carlo_ready.emit(self.mc.cs_name) - - -worker_thread = MonteCarloWorkerThread() - -# TODO review if can be removed - -# class Worker(QtCore.QObject): -# -# def __init__(self): -# super().__init__() -# -# def do_something(self, text): -# print('in thread {} message {}'.format(QtCore.QThread.currentThread(), text)) -# -# -# class SomeObject(QtCore.QObject): -# -# finished = QtCore.pyqtSignal() -# -# def long_running(self): -# count = 0 -# while count < 5: -# time.sleep(1) -# print("B Increasing") -# count += 1 -# self.finished.emit() diff --git a/activity_browser/app/pages/lca_results/__init__.py b/activity_browser/app/pages/lca_results/__init__.py deleted file mode 100644 index ce2edd78d..000000000 --- a/activity_browser/app/pages/lca_results/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .LCA_results import LCAResultsPage diff --git a/activity_browser/app/pages/lca_results/dialogs.py b/activity_browser/app/pages/lca_results/dialogs.py deleted file mode 100644 index 2e83dd39f..000000000 --- a/activity_browser/app/pages/lca_results/dialogs.py +++ /dev/null @@ -1,574 +0,0 @@ -from qtpy import QtWidgets, QtGui -from qtpy.QtCore import Qt - -from activity_browser.ui.icons import qicons - -from .style import vertical_line - - -class ColumnFilterTab(QtWidgets.QWidget): - """Content of column tab. - - Required inputs: - - None - Optional inputs: - - col_type: str --> the type of column, either 'str' or 'num'. defines the search type options. - defaults to 'str' - - state: dict --> dict of existing filter state that should be re-created in UI. - - Interaction: - - def get_state: Provides the state of all relevant filter elements (filter rows, AND/OR menu) - returns: dict - - def set_state: Writes given state dict to UI elements (filter rows, AND/OR menu) - """ - - def __init__( - self, filter_types: dict, col_type: str = "str", state: dict = {}, parent=None - ): - super().__init__(parent) - self.filter_types = filter_types - self.col_type = col_type - - self.add = QtWidgets.QToolButton() - self.add.setIcon(qicons.add) - self.add.setToolTip("Add a new filter for this column") - self.add.clicked.connect(self.add_row) - - self.and_or_buttons = AndOrRadioButtons( - label_text="Combine filters within column:" - ) - if self.col_type == "str": - self.and_or_buttons.set_state("OR") - - self.filter_rows = [] - self.filter_widget_layout = QtWidgets.QVBoxLayout() - self.filter_widget = QtWidgets.QWidget() - self.filter_widget.setLayout(self.filter_widget_layout) - - # set the state, adds 1 empty row if state=={} - self.set_state(state) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.filter_widget) - layout.addWidget(self.add) - layout.addStretch() - layout.addWidget(self.and_or_buttons) - self.setLayout(layout) - - def add_row(self, state: tuple = None) -> None: - """Add a new row to the self.filter_rows.""" - idx = len(self.filter_rows) - - if self.col_type == "num": - new_filter_row = NumFilterRow( - idx=idx, state=state, filter_types=self.filter_types, parent=self - ) - else: - # if none of the above types, assume str - new_filter_row = StrFilterRow( - idx=idx, state=state, filter_types=self.filter_types, parent=self - ) - - self.filter_rows.append(new_filter_row) - self.filter_widget_layout.addWidget(new_filter_row) - self.show_hide_and_or() - - def remove_row(self, idx: int) -> None: - """Remove the row from the setup""" - # remove the row from widget and self.filter_rows - self.filter_widget_layout.itemAt(idx).widget().deleteLater() - self.filter_rows.pop(idx) - # re-index the list of rows - for i, filter_row in enumerate(self.filter_rows): - filter_row.idx = i - # if there would be no remaining rows, add a new empty one - if len(self.filter_rows) == 0: - self.add_row() - self.show_hide_and_or() - - @property - def get_state(self) -> dict: - # check if there are filters - if len(self.filter_rows) == 0: - return None - # check if there are valid filters - valid_filters = [row.get_state for row in self.filter_rows if row.get_state] - if len(valid_filters) == 0: - return None - elif len(valid_filters) == 1: - return {"filters": valid_filters} - else: - return {"filters": valid_filters, "mode": self.and_or_buttons.get_state} - - def set_state(self, state: dict) -> None: - if not state: - self.add_row() - self.and_or_buttons.hide() - return - - # add one row per filter - filters = state["filters"] - self.filter_rows = [] - for filter_state in filters: - self.add_row(filter_state) - - # set state and show/hide the AND/OR widget - self.show_hide_and_or() - if state.get("mode", False): - self.and_or_buttons.set_state(state["mode"]) - - def show_hide_and_or(self) -> None: - if len(self.filter_rows) > 1: - self.and_or_buttons.show() - else: - self.and_or_buttons.hide() - - -class AndOrRadioButtons(QtWidgets.QWidget): - """Convenience class for managing AND/OR buttons. - - This class is purely intended for FilterManagerDialog and related, take this into account if using elsewhere. - - Required inputs: - - None - Optional inputs: - - label_text: str --> - - state: str --> str of existing AND/OR state that should be re-created in UI. - - Interaction: - - def get_state: Provides the state of AND/OR radio buttons (string of 'AND' or 'OR') - returns: str - - def set_state: Writes given AND/OR state UI element (string of 'AND' or 'OR') - """ - - def __init__(self, label_text: str = "", state: str = None, parent=None): - super().__init__(parent) - # create an AND/OR widget - layout = QtWidgets.QHBoxLayout() - self.btn_group = QtWidgets.QButtonGroup() - self.AND = QtWidgets.QRadioButton("AND") - self.OR = QtWidgets.QRadioButton("OR") - self.btn_group.addButton(self.AND) - self.btn_group.addButton(self.OR) - layout.addStretch() - layout.addWidget(QtWidgets.QLabel(label_text)) - layout.addWidget(self.AND) - layout.addWidget(self.OR) - self.setLayout(layout) - self.setToolTip( - "Choose how filters combine with each other.\n" - "AND must satisfy all filters, OR must satisfy at least one filter." - ) - - # set the state if one was given, otherwise, assume AND - if isinstance(state, str): - self.set_state(state) - else: - self.set_state("AND") - - @property - def get_state(self) -> str: - return self.btn_group.checkedButton().text() - - def set_state(self, state: str) -> None: - x = True - if state == "OR": - x = False - self.AND.setChecked(x) - self.OR.setChecked(not x) - - -class FilterRow(QtWidgets.QWidget): - """Convenience class for managing a filter input row. - - This class is purely intended for FilterManagerDialog and related, take this into account if using elsewhere. - - Required inputs: - - idx: int --> integer index in self.filter_rows of parent. Used as ID in parent - idx is the index position of this FilterRow in the list of rows in parent. - - filter_types: dict --> the types of filter available - Optional inputs: - - state: tuple --> tuple of existing filter state that should be re-created in UI. - - Interaction: - - def get_state: Provides the state of all relevant filter fields (filter type, query, case sensitive) - returns: tuple - - def set_state: Writes given state tuple to UI elements (filter type, query, case sensitive) - """ - - def __init__( - self, - idx: int, - filter_types: dict, - remove_option: bool = True, - preset_type: str = None, - parent=None, - ): - super().__init__(parent) - - self.idx = idx - self.filter_types = filter_types - self.filter_type = self.filter_types[self.column_type] - self.parent = parent - - self.row_layout = QtWidgets.QHBoxLayout() - - # create a 'filter type' combobox - self.filter_type_box = QtWidgets.QComboBox() - self.filter_type_box.addItems(self.filter_type) - # set a preset type if given - if isinstance(preset_type, str): - self.filter_type_box.setCurrentIndex(self.filter_type.index(preset_type)) - # add tooltip for every type option - for i, tt in enumerate(self.filter_types[self.column_type + "_tt"]): - self.filter_type_box.setItemData(i, tt, Qt.ToolTipRole) - - # create the filter input line - self.filter_query_line = QtWidgets.QLineEdit() - self.filter_query_line.setFocusPolicy(Qt.StrongFocus) - - if remove_option: - # add buttons to remove the row - self.remove = QtWidgets.QToolButton() - self.remove.setIcon(qicons.delete) - self.remove.setToolTip("Remove this filter") - self.remove.clicked.connect(self.self_destruct) - - @property - def get_state(self) -> tuple: - raise NotImplementedError - - def set_state(self, state: tuple) -> None: - raise NotImplementedError - - def set_input_changes(self) -> None: - raise NotImplementedError - - def self_destruct(self) -> None: - """Remove this FilterRow object from parent.""" - self.parent.remove_row(self.idx) - - -class StrFilterRow(FilterRow): - """Convenience class for managing a filter input row for 'str' type.""" - - def __init__( - self, - idx: int, - filter_types: dict, - state: tuple = None, - remove_option: bool = True, - preset_type: str = None, - parent=None, - ): - - self.column_type = "str" - super().__init__(idx, filter_types, remove_option, preset_type, parent) - - # create case-sensitive box - self.case_sensitive_text = QtWidgets.QLabel("Case Sensitive:") - self.filter_case_sensitive_check = QtWidgets.QCheckBox() - - # assemble the layout - self.row_layout.addWidget(self.filter_type_box) - self.row_layout.addWidget(self.filter_query_line) - self.row_layout.addWidget(self.case_sensitive_text) - self.row_layout.addWidget(self.filter_case_sensitive_check) - if remove_option: - # add button to remove the row - self.row_layout.addWidget(vertical_line()) - self.row_layout.addWidget(self.remove) - - self.setLayout(self.row_layout) - - # set the state if one was given - if isinstance(state, tuple): - self.set_state(state) - - self.filter_type_box.currentIndexChanged.connect(self.set_input_changes) - self.set_input_changes() - - @property - def get_state(self) -> tuple: - # remove weird whitespace from input - query_line = ( - self.filter_query_line.text() - .translate(str.maketrans("", "", "\n\t\r")) - .strip() - ) - # if valid, return a tuple with the state, otherwise, return None - if query_line == "": - return None - - selected_type = self.filter_type_box.currentText() - selected_query = self.filter_query_line.text() - case_sensitive = self.filter_case_sensitive_check.isChecked() - return selected_type, selected_query, case_sensitive - - def set_state(self, state: tuple) -> None: - selected_type, selected_query, case_sensitive = state - self.filter_type_box.setCurrentIndex(self.filter_type.index(selected_type)) - self.filter_query_line.setText(selected_query) - self.filter_case_sensitive_check.setChecked(case_sensitive) - - def set_input_changes(self) -> None: - # set tooltip to currently selected item - tt = self.filter_types[self.column_type + "_tt"][ - self.filter_type_box.currentIndex() - ] - self.filter_type_box.setToolTip(tt) - - -class NumFilterRow(FilterRow): - """Convenience class for managing a filter input row for 'num' type.""" - - def __init__( - self, - idx: int, - filter_types: dict, - state: tuple = None, - remove_option: bool = True, - preset_type: str = None, - parent=None, - ): - - self.column_type = "num" - super().__init__(idx, filter_types, remove_option, preset_type, parent) - - # add an input line in case 'between' ('<= x <=') is selected - self.filter_query_line0 = QtWidgets.QLineEdit() - self.filter_query_line0.hide() - - # set 'double' validator for input lines - self.filter_query_line0.setValidator(QtGui.QDoubleValidator()) - self.filter_query_line.setValidator(QtGui.QDoubleValidator()) - - # assemble the layout - self.row_layout.addWidget(self.filter_query_line0) - self.row_layout.addWidget(self.filter_type_box) - self.row_layout.addWidget(self.filter_query_line) - if remove_option: - # add button to remove the row - self.row_layout.addWidget(vertical_line()) - self.row_layout.addWidget(self.remove) - - self.setLayout(self.row_layout) - - # set the state if one was given - if isinstance(state, tuple): - self.set_state(state) - - self.filter_type_box.currentIndexChanged.connect(self.set_input_changes) - self.set_input_changes() - - @property - def get_state(self) -> tuple: - # remove weird whitespace from input - query_line = ( - self.filter_query_line.text() - .translate(str.maketrans("", "", " \n\t\r")) - .strip() - ) - # if valid, return a tuple with the state, otherwise, return None - if query_line == "": - return None - - selected_type = self.filter_type_box.currentText() - selected_query = self.filter_query_line.text() - if self.filter_type_box.currentText() == "<= x <=": - selected_query = ( - self.filter_query_line0.text(), - self.filter_query_line.text(), - ) - return selected_type, selected_query - - def set_state(self, state: tuple) -> None: - selected_type, selected_query = state - self.set_input_changes() - self.filter_type_box.setCurrentIndex(self.filter_type.index(selected_type)) - if selected_type == "<= x <=": - self.filter_query_line0.setText(selected_query[0]) - self.filter_query_line.setText(selected_query[1]) - else: - self.filter_query_line.setText(selected_query) - - def set_input_changes(self) -> None: - # enable whether the extra input line is visible - if self.filter_type_box.currentText() == "<= x <=": - self.filter_query_line0.show() - else: - self.filter_query_line0.hide() - # set tooltip to currently selected item - tt = self.filter_types[self.column_type + "_tt"][ - self.filter_type_box.currentIndex() - ] - self.filter_type_box.setToolTip(tt) - - - -class SimpleFilterDialog(QtWidgets.QDialog): - """Add one filter to a column. - - Related to FilterManagerDialog. - """ - - def __init__( - self, - column_name: dict, - filter_types: dict, - column_type: str = "str", - preset_type: str = None, - parent=None, - ): - super().__init__(parent) - self.setWindowIcon(qicons.filter) - self.setWindowTitle("Add filter") - - # Create filter label and buttons - label = QtWidgets.QLabel("Define a filter for column '{}'".format(column_name)) - - if column_type == "num": - self.filter_row = NumFilterRow( - idx=0, - filter_types=filter_types, - remove_option=False, - preset_type=preset_type, - parent=self, - ) - else: - # if none of the above types, assume str - self.filter_row = StrFilterRow( - idx=0, - filter_types=filter_types, - remove_option=False, - preset_type=preset_type, - parent=self, - ) - - self.filter_row.filter_query_line.setFocus() - - # create OK/cancel buttons - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - layout = QtWidgets.QVBoxLayout() - layout.addWidget(label) - layout.addWidget(self.filter_row) - layout.addWidget(self.buttons) - self.setLayout(layout) - - @property - def get_filter(self) -> tuple: - if self.filter_row.get_state: - return self.filter_row.get_state - - -class FilterManagerDialog(QtWidgets.QDialog): - """Set filters for a table. - - Dialog has 1 tab per given column. Each tab has rows for filters, - where type/query/other is defined. User can add/remove filters as desired. - When multiple filters exist for 1 column, user can choose AND/OR combination of filters. - AND/OR for combining columns can also be chosen. - - Required inputs: - - column names: dict --> the column names and their indices in the table - format: {'col_name': i} - Optional inputs: - - filters: dict --> pre-apply filters in the dialog (see format example below) - - selected_column: int --> open the dialog with this column tab open - - column_types: dict --> show other filters for this column - format: {'col_name': 'num'} - options: str/num, defaults to str if no type is given - - Interaction: - - call 'start_filter_dialog' of 'ABFilterableDataFrameView' to launch dialog, - filters are only applied when OK is selected. This calls self.get_filters, - which returns filter data as dict. - - example of filters (see also ABMultiColumnSortProxyModel): - filters = { - 0: {'filters': [('contains', 'heat', False), ('contains', 'electricity', False)], - 'mode': 'OR'}, - 1: {'filters': [('contains', 'market', False)]} - } - """ - - def __init__( - self, - column_names: dict, - filter_types: dict, - filters: dict = None, - selected_column: int = 0, - column_types: dict = {}, - parent=None, - ): - super().__init__(parent) - self.setWindowIcon(qicons.filter) - self.setWindowTitle("Manage table filters") - - # set given filters, if any - if isinstance(filters, dict): - self.filters = filters - else: - self.filters = {} - - # create a tab for every column in the table - self.tab_widget = QtWidgets.QTabWidget() - self.tabs = [] - - # we need this dict as we may have hidden columns (e.g. CFTable) - self.col_id_2_tab_id = {} - for tab_id, col_data in enumerate(column_names.items()): - col_name, col_id = col_data - self.col_id_2_tab_id[col_id] = tab_id - tab = ColumnFilterTab( - parent=self, - state=self.filters.get(col_id, None), - col_type=column_types.get(col_name, "str"), - filter_types=filter_types, - ) - self.tabs.append(tab) - self.tab_widget.addTab(tab, col_name) - - # add AND/OR choice button. - self.and_or_buttons = AndOrRadioButtons(label_text="Combine columns:") - # in the extremely unlikely event there is only 1 column, hide the AND/OR option. - if len(column_names) == 1: - self.and_or_buttons.hide() - - # create OK/cancel buttons - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, - ) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - - # assemble layout - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.tab_widget) - layout.addWidget(self.and_or_buttons) - layout.addWidget(self.buttons) - self.setLayout(layout) - - # set the column that launched the dialog as the open tab - self.tab_widget.setCurrentIndex(self.col_id_2_tab_id[selected_column]) - self.tabs[selected_column].filter_rows[-1].filter_query_line.setFocus() - - @property - def get_filters(self) -> dict: - state = {} - t2c = {v: k for k, v in self.col_id_2_tab_id.items()} - for tab_id, tab in enumerate(self.tabs): - tab_state = tab.get_state - if isinstance(tab_state, dict): - state[t2c[tab_id]] = tab_state - if len(state) == 0: - return - state["mode"] = self.and_or_buttons.get_state - return state - - diff --git a/activity_browser/app/pages/lca_results/plots.py b/activity_browser/app/pages/lca_results/plots.py deleted file mode 100644 index a1bc673db..000000000 --- a/activity_browser/app/pages/lca_results/plots.py +++ /dev/null @@ -1,309 +0,0 @@ -import math -from loguru import logger - -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd -import seaborn as sns - -from bw2data import methods -from activity_browser.ui.widgets import ABPlot -from activity_browser.bwutils.commontasks import wrap_text - - - - - -class LCAResultsBarChart(ABPlot): - """ " Generate a bar chart comparing the absolute LCA scores of the products""" - - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "LCA scores" - - def plot(self, df: pd.DataFrame, method: tuple, labels: list): - self.reset_plot() - height_inches, width_inches = self.get_canvas_size_in_inches() - self.figure.set_size_inches(height_inches, width_inches) - - # https://github.com/LCA-ActivityBrowser/activity-browser/issues/489 - df.index = pd.Index(labels) # Replace index of tuples - show_legend = df.shape[1] != 1 # Do not show the legend for 1 column - df.plot.barh(ax=self.ax, legend=show_legend) - self.ax.invert_yaxis() - - # labels - self.ax.set_yticks(np.arange(len(labels))) - self.ax.set_xlabel(methods[method].get("unit")) - self.ax.set_title(", ".join([m for m in method])) - # self.ax.set_yticklabels(labels, minor=False) - - # grid - self.ax.grid(which="major", axis="x", color="grey", linestyle="dashed") - self.ax.set_axisbelow(True) # puts gridlines behind bars - - # draw - self.canvas.draw() - - -class LCAResultsPlot(ABPlot): - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "LCA heatmap" - - def plot(self, df: pd.DataFrame, invert_plot: bool = False): - """Plot a heatmap grid of the different impact categories and reference flows.""" - # need to clear the figure and add axis again - # because of the colorbar which does not get removed by the ax.clear() - self.reset_plot() - - dfp = df.copy() - dfp.index = dfp["index"] - dfp.drop( - dfp.select_dtypes(["object"]), axis=1, inplace=True - ) # get rid of all non-numeric columns (metadata) - if "amount" in dfp.columns: - dfp.drop(["amount"], axis=1, inplace=True) # Drop the 'amount' col - if "Score" in dfp.index: - dfp.drop("Score", inplace=True) - - # avoid figures getting too large horizontally - dfp.index = [wrap_text(i, max_length=40) for i in dfp.index] - dfp.columns = [wrap_text(i, max_length=20) for i in dfp.columns] - prop = dfp.divide(dfp.abs().max(axis=0)).multiply(100) - dfp.replace(np.nan, 0, inplace=True) - if invert_plot: - dfp = dfp.T - prop = prop.T - - # set different color palette depending on whether all values are positive or not - if ( - dfp.min(axis=None) < 0 and dfp.max(axis=None) > 0 - ): # has both negative AND positive values - cmap = sns.color_palette("vlag_r", as_cmap=True) - else: # has only positive OR negative values - cmap = sns.color_palette("Blues", as_cmap=True) - - sns.heatmap( - prop, - ax=self.ax, - cmap=cmap, - annot=dfp, - linewidths=0.05, - annot_kws={ - "size": 11 if dfp.shape[1] <= 8 else 9, - "rotation": 0 if dfp.shape[1] <= 8 else 60, - }, - cbar_kws={"format": "%.0f%%"}, - ) - self.ax.tick_params(labelsize=8) - if dfp.shape[1] > 5: - self.ax.set_xticklabels(self.ax.get_xticklabels(), rotation="vertical") - self.ax.set_yticklabels(self.ax.get_yticklabels(), rotation="horizontal") - - # refresh canvas - size_inches = (2 + dfp.shape[0] * 0.5, 4 + dfp.shape[0] * 0.55) - self.figure.set_size_inches(self.get_canvas_size_in_inches()[0], size_inches[1]) - size_pixels = self.figure.get_size_inches() * self.figure.dpi - self.setMinimumHeight(size_pixels[1]) - - self.canvas.draw() - - -class ContributionPlot(ABPlot): - MAX_LEGEND = 30 - - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "Contributions" - self.parent = parent - - def plot(self, df: pd.DataFrame, unit: str = None): - """Plot a horizontal stacked bar chart of contributions, - add 'total' marker if both positive and negative results are present.""" - dfp = df.copy() - dfp = dfp.iloc[:, ::-1] # reverse column names so they align with calculation setup and rest of results - - dfp.index = dfp["index"] - dfp.drop( - dfp.select_dtypes(["object"]), axis=1, inplace=True - ) # get rid of all non-numeric columns (metadata) - if "Score" in dfp.index: - dfp.drop("Score", inplace=True) - if "id" in dfp: - dfp.drop(columns=["id"], inplace=True) - # drop rows if all values are 0 except for "Rest (+)" and "Rest (-)" - rows_to_drop = dfp.index[(dfp == 0).all(axis=1) & ~dfp.index.isin(["Rest (+)", "Rest (-)"])] - # Drop those rows - dfp = dfp.drop(rows_to_drop) - - self.ax.clear() - canvas_width_inches, canvas_height_inches = self.get_canvas_size_in_inches() - optimal_height_inches = 4 + dfp.shape[1] * 0.55 - # print('Optimal Contribution plot height:', optimal_height_inches) - self.figure.set_size_inches(canvas_width_inches, optimal_height_inches) - - # avoid figures getting too large horizontally - dfp.index = pd.Index([wrap_text(str(i), max_length=40) for i in dfp.index]) - dfp.columns = pd.Index([wrap_text(i, max_length=40) for i in dfp.columns]) - # Strip invalid characters from the ends of row/column headers - dfp.index = dfp.index.str.strip("_ \n\t") - dfp.columns = dfp.columns.str.strip("_ \n\t") - - # set colormap to use - items = dfp.shape[0] # how many contribution items - # skip grey and black at start/end of cmap - cmap = plt.cm.nipy_spectral_r(np.linspace(0, 1, items + 2))[1:-1] - colors = {item: color for item, color in zip(dfp.index, cmap)} - # overwrite rest values to grey - colors["Rest (+)"] = [0.8, 0.8, 0.8, 1.] - colors["Rest (-)"] = [0.8, 0.8, 0.8, 1.] - - dfp.T.plot.barh( - stacked=True, - color=colors, - ax=self.ax, - legend=False if dfp.shape[0] >= self.MAX_LEGEND else True, - ) - self.ax.tick_params(labelsize=8) - if unit: - self.ax.set_xlabel(unit) - - # show legend if not too many items - if not dfp.shape[0] >= self.MAX_LEGEND: - plt.rc("legend", **{"fontsize": 8}) - ncols = math.ceil(dfp.shape[0] * 0.6 / optimal_height_inches) - # print('Ncols:', ncols, dfp.shape[0] * 0.55, optimal_height_inches) - self.ax.legend(loc="center left", bbox_to_anchor=(1, 0.5), ncol=ncols) - - # grid - self.ax.grid(which="major", axis="x", color="grey", linestyle="dashed") - self.ax.set_axisbelow(True) # puts gridlines behind bars - # make the zero line more present - grid = self.ax.get_xgridlines() - # get the 0 line from all gridlines - label_pos = [i for i, label in enumerate(self.ax.get_xticklabels()) if label.get_position()[0] == 0.0] - if len(label_pos) > 0: - zero_line = grid[label_pos[0]] - zero_line.set_color("black") - zero_line.set_linestyle("solid") - - # total marker when enabled and both negative and positive results are present in a column - if self.parent.score_marker: - marker_size = max(min(150 / dfp.shape[1], 35), 10) # set marker size dynamic between 10 - 35 - for i, col in enumerate(dfp): - total = np.sum(dfp[col]) - abs_total = np.sum(np.abs(dfp[col])) - if abs(total) != abs_total: - self.ax.plot(total, i, - markersize=marker_size, marker="d", fillstyle="left", - markerfacecolor="black", markerfacecoloralt="grey", markeredgecolor="white") - - # TODO review: remove or enable - - # refresh canvas - # size_inches = (2 + dfp.shape[0] * 0.5, 4 + dfp.shape[1] * 0.55) - # self.figure.set_size_inches(self.get_canvas_size_in_inches()[0], size_inches[1]) - - size_pixels = self.figure.get_size_inches() * self.figure.dpi - self.setMinimumHeight(size_pixels[1]) - self.canvas.draw() - - -class CorrelationPlot(ABPlot): - def __init__(self, parent=None): - super().__init__(parent) - sns.set(style="darkgrid") - - def plot(self, df: pd.DataFrame): - """Plot a heatmap of correlations between different reference flows.""" - # need to clear the figure and add axis again - # because of the colorbar which does not get removed by the ax.clear() - self.reset_plot() - canvas_size = self.canvas.get_width_height() - # print("Canvas size:", canvas_size) - size = (4 + df.shape[1] * 0.3, 4 + df.shape[1] * 0.3) - self.figure.set_size_inches(size[0], size[1]) - - corr = df.corr() - # Generate a mask for the upper triangle - mask = np.zeros_like(corr, dtype=bool) - mask[np.triu_indices_from(mask)] = True - # Draw the heatmap with the mask and correct aspect ratio - vmax = np.abs(corr.values[~mask]).max() - # vmax = np.abs(corr).max() - sns.heatmap( - corr, - mask=mask, - cmap=plt.cm.PuOr, - vmin=-vmax, - vmax=vmax, - square=True, - linecolor="lightgray", - linewidths=1, - ax=self.ax, - ) - - df_lte8_cols = df.shape[1] <= 8 - for i in range(len(corr)): - self.ax.text( - i + 0.5, - i + 0.5, - corr.columns[i], - ha="center", - va="center", - rotation=0 if df_lte8_cols else 45, - size=11 if df_lte8_cols else 9, - ) - for j in range(i + 1, len(corr)): - s = "{:.3f}".format(corr.values[i, j]) - self.ax.text( - j + 0.5, - i + 0.5, - s, - ha="center", - va="center", - rotation=0 if df_lte8_cols else 45, - size=11 if df_lte8_cols else 9, - ) - self.ax.axis("off") - - # refresh canvas - size_pixels = self.figure.get_size_inches() * self.figure.dpi - self.setMinimumHeight(size_pixels[1]) - self.canvas.draw() - - -class MonteCarloPlot(ABPlot): - """Monte Carlo plot.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "Monte Carlo" - - def plot(self, df: pd.DataFrame, method: tuple): - self.ax.clear() - - for col in df.columns: - color = self.ax._get_lines.get_next_color() - df[col].hist( - ax=self.ax, - figure=self.figure, - label=col, - density=True, - color=color, - alpha=0.5, - ) # , histtype="step") - # self.ax.axvline(df[col].median(), color=color) - self.ax.axvline(df[col].mean(), color=color) - - self.ax.set_xlabel(methods[method]["unit"]) - self.ax.set_ylabel("Probability") - self.ax.legend( - loc="upper center", - bbox_to_anchor=(0.5, -0.07), - ) # ncol=2 - - # lconfi, upconfi =mc['statistics']['interval'][0], mc['statistics']['interval'][1] - - self.canvas.draw() diff --git a/activity_browser/app/pages/lca_results/sankey_navigator.py b/activity_browser/app/pages/lca_results/sankey_navigator.py deleted file mode 100644 index a71948e4b..000000000 --- a/activity_browser/app/pages/lca_results/sankey_navigator.py +++ /dev/null @@ -1,473 +0,0 @@ -# -*- coding: utf-8 -*- -import json -import os -import time -from typing import List -from loguru import logger - -import bw2calc as bc -import numpy -from bw_graph_tools.graph_traversal import Edge as GraphEdge -from bw_graph_tools.graph_traversal import NewNodeEachVisitGraphTraversal -from bw_graph_tools.graph_traversal import Node as GraphNode -from qtpy import QtWidgets -from qtpy.QtCore import Slot -from qtpy.QtWidgets import QComboBox - -from activity_browser import app -from activity_browser.mod import bw2data as bd -from bw2data.backends import ActivityDataset - -from activity_browser.bwutils.commontasks import identify_activity_type -from activity_browser.bwutils.filesystem import get_package_path -from activity_browser.ui import widgets - - - -class SankeyNavigatorWidget(widgets.ABAbstractNavigator): - HELP_TEXT = """ - LCA Sankey: - - Red flows: Impacts - Green flows: Avoided impacts - - """ - HTML_FILE = str(get_package_path() / "static" / "sankey_navigator.html") - - def __init__(self, cs_name, parent=None): - super().__init__(parent, css_file="sankey_navigator.css") - - self.cache = {} # we cache the calculated data to improve responsiveness - self.parent = parent - self.has_scenarios = self.parent.has_scenarios - self.cs = cs_name - self.selected_db = None - self.has_sankey = False - self.func_units = [] - self.methods = [] - self.scenarios = [] - self.graph = Graph() - - # Additional Qt objects - self.scenario_label = QtWidgets.QLabel("Scenario: ") - self.func_unit_cb = QtWidgets.QComboBox() - self.method_cb = QtWidgets.QComboBox() - self.scenario_cb = QtWidgets.QComboBox() - self.cutoff_sb = QtWidgets.QDoubleSpinBox() - self.max_calc_sb = QtWidgets.QDoubleSpinBox() - self.button_calculate = QtWidgets.QPushButton("Calculate") - self.layout = QtWidgets.QVBoxLayout() - - # graph - self.draw_graph() - self.construct_layout() - self.connect_signals() - - @Slot(name="loadFinishedHandler") - def load_finished_handler(self) -> None: - if self.has_sankey: - self.send_json() - - def connect_signals(self): - super().connect_signals() - self.button_calculate.clicked.connect(self.new_sankey) - app.signals.database_selected.connect(self.set_database) - # checkboxes - self.func_unit_cb.currentIndexChanged.connect(self.new_sankey) - self.method_cb.currentIndexChanged.connect(self.new_sankey) - self.scenario_cb.currentIndexChanged.connect(self.new_sankey) - - def construct_layout(self) -> None: - """Layout of Sankey Navigator""" - super().construct_layout() - self.label_help.setVisible(False) - - # Layout Reference Flows and Impact Categories - grid_lay = QtWidgets.QGridLayout() - grid_lay.addWidget(QtWidgets.QLabel("Reference flow: "), 0, 0) - - grid_lay.addWidget(self.scenario_label, 1, 0) - grid_lay.addWidget(QtWidgets.QLabel("Impact indicator: "), 2, 0) - - self.update_calculation_setup() - - grid_lay.addWidget(self.func_unit_cb, 0, 1) - grid_lay.addWidget(self.scenario_cb, 1, 1) - grid_lay.addWidget(self.method_cb, 2, 1) - - # cut-off - grid_lay.addWidget(QtWidgets.QLabel("Cutoff: "), 2, 2) - self.cutoff_sb.setRange(0.0, 1.0) - self.cutoff_sb.setSingleStep(0.01) - self.cutoff_sb.setDecimals(3) - self.cutoff_sb.setValue(0.05) - self.cutoff_sb.setKeyboardTracking(False) - grid_lay.addWidget(self.cutoff_sb, 2, 3) - - # max-iterations of graph traversal - grid_lay.addWidget(QtWidgets.QLabel("Calculation depth: "), 2, 4) - self.max_calc_sb.setRange(1, 2000) - self.max_calc_sb.setSingleStep(50) - self.max_calc_sb.setDecimals(0) - self.max_calc_sb.setValue(250) - self.max_calc_sb.setKeyboardTracking(False) - grid_lay.addWidget(self.max_calc_sb, 2, 5) - - grid_lay.setColumnStretch(6, 1) - hlay = QtWidgets.QHBoxLayout() - hlay.addLayout(grid_lay) - - # Controls Layout - hl_controls = QtWidgets.QHBoxLayout() - hl_controls.addWidget(self.button_back) - hl_controls.addWidget(self.button_forward) - hl_controls.addWidget(self.button_calculate) - hl_controls.addWidget(self.button_refresh) - hl_controls.addWidget(self.button_random_activity) - hl_controls.addWidget(self.button_toggle_help) - hl_controls.addStretch(1) - - # Layout - self.layout.addLayout(hl_controls) - self.layout.addLayout(hlay) - self.layout.addWidget(self.label_help) - self.layout.addWidget(self.view) - self.setLayout(self.layout) - - def get_scenario_labels(self) -> List[str]: - """Get scenario labels if scenario is used.""" - return self.parent.mlca.scenario_names if self.has_scenarios else [] - - def configure_scenario(self): - """Determine if scenario Qt widgets are visible or not and retrieve - scenario labels for the selection drop-down box. - """ - self.scenario_cb.setVisible(self.has_scenarios) - self.scenario_label.setVisible(self.has_scenarios) - if self.has_scenarios: - self.scenarios = self.get_scenario_labels() - self.update_combobox(self.scenario_cb, self.scenarios) - - @staticmethod - def update_combobox(box: QComboBox, labels: List[str]) -> None: - """Update the combobox menu.""" - box.blockSignals(True) - box.clear() - box.insertItems(0, labels) - box.blockSignals(False) - - def update_calculation_setup(self, cs_name=None) -> None: - """Update Calculation Setup, reference flows and impact categories, and dropdown menus.""" - # block signals - self.func_unit_cb.blockSignals(True) - self.method_cb.blockSignals(True) - - self.cs = cs_name or self.cs - self.func_units = [ - {bd.get_activity(k): v for k, v in fu.items()} - for fu in bd.calculation_setups[self.cs]["inv"] - ] - self.methods = bd.calculation_setups[self.cs]["ia"] - self.func_unit_cb.clear() - fu_acts = [list(fu.keys())[0] for fu in self.func_units] - self.func_unit_cb.addItems( - [f"{repr(a)} | {a._data.get('database')}" for a in fu_acts] - ) - self.configure_scenario() - self.method_cb.clear() - self.method_cb.addItems([repr(m) for m in self.methods]) - - # unblock signals - self.func_unit_cb.blockSignals(False) - self.method_cb.blockSignals(False) - - def new_sankey(self) -> None: - """(re)-generate the sankey diagram.""" - demand_index = self.func_unit_cb.currentIndex() - method_index = self.method_cb.currentIndex() - - demand = self.func_units[demand_index] - method = self.methods[method_index] - scenario_index = None - scenario_lca = False - if self.has_scenarios: - scenario_lca = True - scenario_index = self.scenario_cb.currentIndex() - self.update_sankey( - demand, - method, - demand_index=demand_index, - method_index=method_index, - scenario_index=scenario_index, - scenario_lca=scenario_lca, - cut_off=self.cutoff_sb.value(), - max_calc=int(self.max_calc_sb.value()), - ) - - def update_sankey( - self, - demand: dict, - method: tuple, - demand_index: int = None, - method_index: int = None, - scenario_index: int = None, - scenario_lca: bool = False, - cut_off=0.05, - max_calc=100, - ) -> None: - """Calculate LCA, do graph traversal, get JSON graph data for this, and send to javascript.""" - - # the cache key consists of demand/method/scenario indices (index of item in the relevant tables), - # the cutoff, max_calc. - # together, these are unique. - cache_key = (demand_index, method_index, scenario_index, cut_off, max_calc) - if data := self.cache.get(cache_key, False): - # this Sankey is already cached, generate the Sankey with the cached data - logger.debug(f"CACHED sankey for: {demand}, {method}, key: {cache_key}") - self.graph.new_graph(data) - self.has_sankey = bool(self.graph.json_data) - self.send_json() - return - - start = time.time() - logger.debug(f"CALCULATE sankey for: {demand}, {method}, key: {cache_key}") - try: - if scenario_lca: - self.parent.mlca.update_lca_calculation_for_sankey( - scenario_index, demand, method_index - ) - lca = self.parent.mlca.lca - data = NewNodeEachVisitGraphTraversal.calculate( - lca, cutoff=cut_off, max_calc=int(max_calc) - ) - else: - fu, data_objs, _ = bd.prepare_lca_inputs(demand=demand, method=method) - lca = bc.LCA(demand=fu, data_objs=data_objs) - lca.lci(factorize=True) - lca.lcia() - data = NewNodeEachVisitGraphTraversal.calculate( - lca_object=lca, cutoff=cut_off, max_calc=int(max_calc) - ) - - # store the metadata from this calculation - data["metadata"] = { - "lca": lca, - "unit": bd.methods[method]["unit"], - } - except (ValueError, ZeroDivisionError) as e: - QtWidgets.QMessageBox.information( - None, "Nonsensical numeric result.", str(e) - ) - logger.debug(f"Completed graph traversal ({round(time.time() - start, 2)} seconds") - - # cache the generated Sankey data - self.cache[cache_key] = data - - # generate the new Sankey - self.graph.new_graph(data) - self.has_sankey = bool(self.graph.json_data) - self.send_json() - - def set_database(self, name): - """Saves the currently selected database for graphing a random activity""" - self.selected_db = name - - def random_graph(self) -> None: - """Show graph for a random activity in the currently loaded database.""" - if self.selected_db: - method = bd.methods.random() - act = bd.Database(self.selected_db).random() - demand = {act: 1.0} - self.update_sankey(demand, method) - else: - QtWidgets.QMessageBox.information( - None, "Not possible.", "Please load a database first." - ) - - -def convert_numpy_types(obj) -> int | float | list: - """Converts numpy types into serializable types""" - if isinstance(obj, numpy.integer): - return int(obj) - if isinstance(obj, numpy.floating): - return float(obj) - if isinstance(obj, numpy.ndarray): - return obj.tolist() - return obj - - -def make_serializable(data: dict) -> dict: - """Converts numpy data into serializable values for json.dumps""" - for key, value in data.items(): - if isinstance(value, dict): - make_serializable(value) - elif isinstance(value, list): - data[key] = [ - ( - convert_numpy_types(v) - if not isinstance(v, dict) - else make_serializable(v) - ) - for v in value - ] - else: - data[key] = convert_numpy_types(value) - return data - - -class Graph(widgets.ABAbstractGraph): - """ - Python side representation of the graph. - Functionality for graph navigation (e.g. adding and removing nodes). - A JSON representation of the graph (edges and nodes) enables its use in javascript/html/css. - """ - - def new_graph(self, data): - self.json_data = Graph.get_json_data(data) - self.update() - - @staticmethod - def get_json_data(data) -> str: - """Transform graph traversal output to JSON data. - - We use the [dagre](https://github.com/dagrejs/dagre) javascript library for rendering directed graphs. We need to provide the following: - - ```python - { - 'max_impact': float, # Total LCA score, - 'title': str, # Graph title - 'edges': [{ - 'source_id': int, # Unique ID of producer of material or energy in graph - 'target_id': int, # Unique ID of consumer of material or energy in graph - 'weight': float, # In graph units, relative to `max_edge_width` - 'label': str, # HTML label - 'product': str, # The label of the flowing material or energy - 'class': str, # "benefit" or "impact"; controls styling - 'label': str, # HTML label - 'toottip': str, # HTML tooltip - }], - 'nodes': [{ - 'direct_emissions_score_normalized': float, # Fraction of total LCA score from direct emissions - 'product': str, # Reference product label, if any - 'location': str, # Location, if any - 'id': int, # Graph traversal ID - 'database_id': int, # Node ID in SQLite database - 'database': str, # Database name - 'class': str, # Enumerated set of class label strings - 'label': str, # HTML label including name and location - 'toottip': str, # HTML tooltip - }], - } - - ``` - - """ - lca_score = data["metadata"]["lca"].score - lcia_unit = data["metadata"]["unit"] - demand = data["metadata"]["lca"].demand - - def convert_edge_to_json( - edge: GraphEdge, - nodes: dict[int, GraphNode], - total_score: float, - lcia_unit: str, - max_edge_width: int = 40, - ) -> dict: - cum_score = nodes[edge.producer_unique_id].cumulative_score - unit = bd.get_node( - id=nodes[edge.producer_unique_id].reference_product_datapackage_id - ).get("unit", "(unknown)") - return { - "source_id": edge.producer_unique_id, - "target_id": edge.consumer_unique_id, - "amount": edge.amount, - "weight": abs(cum_score / total_score) * max_edge_width, - "label": f"{round(cum_score, 3)} {lcia_unit}", - "class": "benefit" if cum_score < 0 else "impact", - "tooltip": f"{round(cum_score, 3)} {lcia_unit} ({edge.amount:.2g} {unit})", - } - - def convert_node_to_json( - graph_node: GraphNode, - total_score: float, - fu: dict, - lcia_unit: str, - max_name_length: int = 20, - ) -> dict: - db_node = bd.get_node(id=graph_node.activity_datapackage_id) - data = { - "direct_emissions_score_normalized": graph_node.direct_emissions_score - / (total_score or 1), - "direct_emissions_score": graph_node.direct_emissions_score, - "cumulative_score": graph_node.cumulative_score, - "cumulative_score_normalized": graph_node.cumulative_score - / (total_score or 1), - "product": db_node.get("reference product", ""), - "location": db_node.get("location", "(unknown)"), - "id": graph_node.unique_id, - "database_id": graph_node.activity_datapackage_id, - "database": db_node["database"], - "class": ( - "demand" - if graph_node.activity_datapackage_id in fu - else identify_activity_type(db_node) - ), - "name": db_node.get("name", "(unnamed)"), - } - frac_dir_score = round(data["direct_emissions_score_normalized"] * 100, 2) - dir_score = round(data["direct_emissions_score"], 3) - frac_cum_score = round(data["cumulative_score_normalized"] * 100, 2) - cum_score = round(data["cumulative_score"], 3) - data[ - "label" - ] = f"""{db_node['name'][:max_name_length]} -{data['location']} -{frac_dir_score}%""" - data[ - "tooltip" - ] = f""" - {data['name']} -
Individual impact: {dir_score} {lcia_unit} ({frac_dir_score }%) -
Cumulative impact: {cum_score} {lcia_unit} ({frac_cum_score}%) - """ - return data - - json_data = { - "nodes": [ - convert_node_to_json(node, lca_score, demand, lcia_unit) - for idx, node in data["nodes"].items() - if idx != -1 - ], - "edges": [ - convert_edge_to_json(edge, data["nodes"], lca_score, lcia_unit) - for edge in data["edges"] - if edge.producer_index != -1 and edge.consumer_index != -1 - ], - "title": "Sankey graph result", - # "title": self.build_title(demand, lca_score, lcia_unit), - } - - return json.dumps(json_data) - - def build_title(self, demand: tuple, lca_score: float, lcia_unit: str) -> str: - act, amount = demand[0], demand[1] - if type(act) is tuple or type(act) is int: - act = bd.get_activity(act) - format_str = ( - "Reference flow: {:.2g} {} {} | {} | {}
" "Total impact: {:.2g} {}" - ) - return format_str.format( - amount, - act.get("unit"), - act.get("reference product") or act.get("name"), - act.get("name"), - act.get("location"), - lca_score, - lcia_unit, - ) - - -def id_to_key(id): - if isinstance(id, tuple): - return id - return ActivityDataset.get_by_id(id).database, ActivityDataset.get_by_id(id).code diff --git a/activity_browser/app/pages/lca_results/style.py b/activity_browser/app/pages/lca_results/style.py deleted file mode 100644 index 29d749cf3..000000000 --- a/activity_browser/app/pages/lca_results/style.py +++ /dev/null @@ -1,25 +0,0 @@ -from qtpy import QtWidgets, QtGui - -def horizontal_line(): - line = QtWidgets.QFrame() - line.setFrameShape(QtWidgets.QFrame.HLine) - line.setFrameShadow(QtWidgets.QFrame.Sunken) - return line - - -def vertical_line(): - line = QtWidgets.QFrame() - line.setFrameShape(QtWidgets.QFrame.VLine) - line.setFrameShadow(QtWidgets.QFrame.Sunken) - return line - - -def header(text): - label = QtWidgets.QLabel(text) - - bold_font = QtGui.QFont() - bold_font.setBold(True) - bold_font.setPointSize(12) - - label.setFont(bold_font) - return label \ No newline at end of file diff --git a/activity_browser/app/pages/lca_results/tables.py b/activity_browser/app/pages/lca_results/tables.py deleted file mode 100644 index a44677581..000000000 --- a/activity_browser/app/pages/lca_results/tables.py +++ /dev/null @@ -1,989 +0,0 @@ -import os -import datetime -from typing import Optional, Any -from loguru import logger - -import arrow -import numpy as np -import bw2data as bd -import pandas as pd - -from qtpy import QtGui, QtWidgets, QtCore -from qtpy.QtCore import QPoint, QRect, QSize, Qt, QTimer, Signal, Slot, SignalInstance -from qtpy.QtWidgets import QSizePolicy, QTableView - -from activity_browser.ui.icons import qicons -from activity_browser.ui import delegates -from activity_browser.bwutils import filesystem - -from .dialogs import FilterManagerDialog, SimpleFilterDialog - - - - - -class CustomHeader(QtWidgets.QHeaderView): - """Header which has a filter button on each cell that can trigger a signal. - - Largely based on https://stackoverflow.com/a/30938728 - """ - - clicked: SignalInstance = Signal(int, str) - - _x_offset = 0 - _y_offset = ( - 0 # This value is calculated later, based on the height of the paint rect - ) - _width = 18 - _height = 18 - - def __init__(self, orientation=Qt.Horizontal, parent=None): - super(CustomHeader, self).__init__(orientation, parent) - self.setSectionsClickable(True) - - self.column_indices = [] - self.has_active_filters = [] # list of column indices that have filters active - self.event_pos = None - - def paintSection(self, painter, rect, logical_index): - """Paint the button onto the column header.""" - painter.save() - super(CustomHeader, self).paintSection(painter, rect, logical_index) - painter.restore() - - self._y_offset = int(rect.height() - self._width) - - if logical_index in self.column_indices: - option = QtWidgets.QStyleOptionButton() - option.rect = QRect( - rect.x() + self._x_offset, - rect.y() + self._y_offset, - self._width, - self._height, - ) - option.state = ( - QtWidgets.QStyle.State_Enabled | QtWidgets.QStyle.State_Active - ) - - # put the filter icon onto the label - if logical_index in self.has_active_filters: - option.icon = qicons.filter - else: - option.icon = qicons.filter_outline - option.iconSize = QSize(16, 16) - - # set the settings to a PushButton - self.style().drawControl(QtWidgets.QStyle.CE_PushButton, option, painter) - - def mousePressEvent(self, event): - index = self.logicalIndexAt(event.pos()) - if index in self.column_indices: - x = self.sectionPosition(index) - if ( - x + self._x_offset < event.pos().x() < x + self._x_offset + self._width - and self._y_offset < event.pos().y() < self._y_offset + self._height - ): - # the button is clicked - - # set the position of the lower left point of the filter button to spawn a menu - pos = QPoint() - pos.setX(x + self._x_offset + self._width) - pos.setY(self._y_offset + self._height) - self.event_pos = pos - - # emit the column index and the button (left/right) pressed - self.clicked.emit(index, str(event.button()).split(".")[-1]) - else: - # pass the event to the header (for sorting) - super(CustomHeader, self).mousePressEvent(event) - else: - # pass the event to the header (for sorting) - super(CustomHeader, self).mousePressEvent(event) - self.viewport().update() - - -class PandasModel(QtCore.QAbstractTableModel): - """Abstract pandas table model adapted from - https://stackoverflow.com/a/42955764. - """ - - HEADERS = [] - updated: SignalInstance = Signal() - - def __init__(self, df: pd.DataFrame = None, parent=None): - super().__init__(parent) - self._dataframe: Optional[pd.DataFrame] = df - self.filterable_columns = None - self.different_column_types = {} - # The list of columns which should be editable by the builtin checkbox editor - # The value of the dict holds whether the value should also be displayed as text - self._checkbox_editors: dict[int, tuple[bool, Any, Any]] = {} - self._columns: list[str] = [] - - @property - def columns(self) -> list[str]: - if self._dataframe is not None: - return self._dataframe.columns - return [] - - def rowCount(self, parent=None, *args, **kwargs): - return 0 if self._dataframe is None else self._dataframe.shape[0] - - def columnCount(self, parent=None, *args, **kwargs): - return 0 if self._dataframe is None else self._dataframe.shape[1] - - def data(self, index, role=Qt.DisplayRole): - """ - Return value for table index based on a certain DisplayRole enum. - - More on DisplayRole enums: https://doc.qt.io/qt-5/qt.html#ItemDataRole-enum - """ - if not index.isValid(): - return None - # instantiate value only in case of DisplayRole or ToolTipRole - value = None - tt_date_flag = False # flag to indicate if value is datetime object and role is ToolTipRole - if role in [Qt.DisplayRole, Qt.ToolTipRole, "sorting", Qt.EditRole]: - value = self._dataframe.iat[index.row(), index.column()] - if isinstance(value, np.float64): - value = float(value) - elif isinstance(value, bool): - value = str(value) - elif isinstance(value, np.int64): - value = value.item() - elif isinstance(value, tuple): - value = str(value) - elif isinstance(value, datetime.datetime) and ( - Qt.DisplayRole or Qt.ToolTipRole - ): - tz = datetime.datetime.now(datetime.timezone.utc).astimezone() - time_shift = -tz.utcoffset().total_seconds() - if role == Qt.ToolTipRole: - value = ( - arrow.get(value) - .shift(seconds=time_shift) - .format("YYYY-MM-DD HH:mm:ss") - ) - tt_date_flag = True - elif role == Qt.DisplayRole: - value = arrow.get(value).shift(seconds=time_shift).humanize() - - # Handle checkbox editors - # Checkbox editors can return two values for one cell: the usual display value - # and a checked / not checked enum. It is useful to return both, when the - # underlying data is not bool, but text to visualize eventual errors. - if index.column() in self._checkbox_editors: - if role == Qt.ItemDataRole.CheckStateRole: - value = self._dataframe.iat[index.row(), index.column()] - if isinstance(value, str): - logger.error(f"Expected bool, received str: {value}!!") - true_value = self._checkbox_editors[index.column()][1] - # Convert the data to an appropriate value for the checkbox - return Qt.CheckState.Checked if value == true_value else Qt.CheckState.Unchecked - display_value = self._checkbox_editors[index.column()][0] - if role == Qt.ItemDataRole.DisplayRole and not display_value: - return None - - # immediately return value in case of DisplayRole or sorting - if role == Qt.DisplayRole or role == "sorting": - return value - - # in case of ToolTipRole and date, always show the full date - if tt_date_flag and role == Qt.ToolTipRole: - return value - - # in case of ToolTipRole, check whether content fits the cell - if role == Qt.ToolTipRole: - parent = self.parent() - fontMetrics = parent.fontMetrics() - - # get the width of both the cell, and the text - column_width = parent.columnWidth(index.column()) - text_width = fontMetrics.horizontalAdvance(str(value)) - margin = 10 - - # only show tooltip if the text is wider then the cell minus the margin - if text_width > column_width - margin: - return value - - return None - - def flags(self, index): - return Qt.ItemIsSelectable | Qt.ItemIsEnabled - - def headerData(self, section, orientation, role=Qt.DisplayRole): - if orientation == Qt.Horizontal and role == Qt.DisplayRole: - return self._dataframe.columns[section] - elif orientation == Qt.Vertical and role == Qt.DisplayRole: - return self._dataframe.index[section] - return None - - def row_data(self, index: int) -> list: - """Return the row at index as a list.""" - return self._dataframe.iloc[index, :].tolist() - - def to_clipboard(self, rows, columns, include_header: bool = False): - """Copy the given rows and columns of the dataframe to clipboard""" - self._dataframe.iloc[rows, columns].to_clipboard( - index=False, header=include_header - ) - - def to_csv(self, path: str) -> None: - """Store the dataframe as csv in the given path.""" - self._dataframe.to_csv(path) - - def to_excel(self, path: str) -> None: - """Store the underlying dataframe as excel in the given path""" - self._dataframe.to_excel(excel_writer=path) - - def sync(self, *args, **kwargs) -> None: - """(Re)build the dataframe according to the given arguments.""" - self._dataframe = pd.DataFrame([], columns=self.HEADERS) - - @staticmethod - def proxy_to_source(proxy: QtCore.QModelIndex) -> QtCore.QModelIndex: - """Step from the QSortFilterProxyModel to the underlying PandasModel.""" - model = proxy.model() - if not hasattr(model, "mapToSource"): - return proxy # Proxy is actually the PandasModel - return model.mapToSource(proxy) - - @staticmethod - def test_query_on_column(test_type: str, col_data: pd.Series, query) -> bool: - """Compare query and col_data on test_type, return array with boolean test results.""" - if test_type == "equals": - return col_data == query - elif test_type == "does not equal": - return col_data != query - elif test_type == "contains": - return col_data.str.contains(query, regex=False) - elif test_type == "does not contain": - return ~col_data.str.contains(query, regex=False) - elif test_type == "starts with": - return col_data.str.startswith(query) - elif test_type == "does not start with": - return ~col_data.str.startswith(query) - elif test_type == "ends with": - return col_data.str.endswith(query) - elif test_type == "does not end with": - return ~col_data.str.endswith(query) - elif test_type == "=": - return col_data.astype(float) == float(query) - elif test_type == "!=": - return col_data.astype(float) != float(query) - elif test_type == ">=": - return col_data.astype(float) >= float(query) - elif test_type == "<=": - return col_data.astype(float) <= float(query) - elif test_type == "<= x <=": - return (float(query[0]) <= col_data.astype(float)) & ( - col_data.astype(float) <= float(query[1]) - ) - else: - logger.warning("unknown filter type >{}<, assuming 'EQUALS'".format(test_type)) - return col_data == query - - def get_filter_mask(self, filters: dict) -> pd.Series: - """Generate a filter mask of the dataframe based on the filters. - - Returns a pd.Series of boolean results (the mask). - """ - # get the column name from index - fc_rev = {v: k for k, v in self.filterable_columns.items()} - - all_mode = filters["mode"] - all_mask = None - # iterate over columns - for col_idx, col_filters in filters.items(): - if col_idx == "mode": - continue - col_name = fc_rev[col_idx] - col_data = self._dataframe[col_name] - col_mode = col_filters.get("mode", False) - col_mask = None - # iterate over filters within column - for col_filt in col_filters["filters"]: - if self.different_column_types.get(col_name, False): - # this is a 'num' column - filt_type, query = col_filt - col_data_ = col_data - else: - # this is a 'str' column - filt_type, query, case_sensitive = col_filt - if case_sensitive: - col_data_ = col_data.astype(str) - else: - col_data_ = col_data.astype(str).str.upper() - query = query.upper() - - # run the test - new_mask = self.test_query_on_column(filt_type, col_data_, query) - if not any(new_mask): - # no matches for this mask, let user know: - logger.info( - "There were no matches for filter: {}: '{}'".format( - col_filt[0], col_filt[1] - ) - ) - - # create or combine new mask within column - if isinstance(col_mask, pd.Series) and col_mode == "AND": - col_mask = col_mask & new_mask - elif isinstance(col_mask, pd.Series) and col_mode == "OR": - col_mask = col_mask + new_mask - else: - col_mask = new_mask - - # create or combine new mask on columns - if isinstance(all_mask, pd.Series) and all_mode == "AND": - all_mask = all_mask & col_mask - elif isinstance(all_mask, pd.Series) and all_mode == "OR": - all_mask = all_mask + col_mask - else: - all_mask = col_mask - return all_mask - - def set_read_only(self, read_only: bool): - """Interface function, to support editable models""" - pass - - def is_read_only(self) -> bool: - """Interface function, to support editable models""" - return True - - def set_builtin_checkbox_delegate(self, column: int, show_text_value: bool, - true_value: Any = True, false_value: Any = False): - """ - Enables the builtin checkbox delegate for columns. - Can be used on bool values only. - As the underlying data can be bool or string, we provide the values to be - stored as parameters. - """ - self._checkbox_editors[column] = (show_text_value, true_value, false_value) - - -class ABSortProxyModel(QtCore.QSortFilterProxyModel): - """Reimplementation to allow for sorting on the actual data in cells instead of the visible data. - - See this for context: https://github.com/LCA-ActivityBrowser/activity-browser/pull/1151 - """ - - def lessThan(self, left: QtCore.QModelIndex, right: QtCore.QModelIndex) -> bool: - """Override to sort actual data, expects `left` and `right` are comparable. - - If `left` and `right` are not the same type, we check if numerical and empty string are compared, if that is the - case, we assume empty string == 0. - Added this case for: https://github.com/LCA-ActivityBrowser/activity-browser/issues/1215 - """ - left_data = self.sourceModel().data(left, "sorting") - right_data = self.sourceModel().data(right, "sorting") - - if not left_data and not right_data: - return True - if type(left_data) is type(right_data): - return left_data < right_data - - # comparing Falsys with types - if (isinstance(left_data, (int, float)) - and not right_data - ): # comparing left number with nothing, compare against '0' instead - return left_data < 0 - if (isinstance(left_data, str) - and not right_data - ): # comparing left str with nothing, compare against "" instead - return left_data < "" # note we use '>' instead of '<', content should be above empty fields - if (isinstance(right_data, (int, float)) - and not left_data - ): # comparing right number with nothing, compare against '0' instead - return 0 < right_data - if (isinstance(right_data, str) - and not left_data - ): # comparing right str with nothing, compare against "" instead - return right_data < "" # note we use '>' instead of '<', content should be above empty fields - - raise ValueError( - f"Cannot compare {left_data} and {right_data}, incompatible types." - ) - - -class ABMultiColumnSortProxyModel(ABSortProxyModel): - """Subclass of QSortFilterProxyModel to enable sorting on multiple columns. - - The main purpose of this subclass is to override def filterAcceptsRow(). - - Subclass based on various ideas from: - https://stackoverflow.com/questions/47201539/how-to-filter-multiple-column-in-qtableview - http://www.dayofthenewdan.com/2013/02/09/Qt_QSortFilterProxyModel.html - https://gist.github.com/dbridges/4732790 - """ - - def __init__(self, parent=None): - super(ABMultiColumnSortProxyModel, self).__init__(parent) - - # the filter mask, an iterable array with boolean values on whether or not to keep the row - self.mask = None - - # metric to keep track of successful matches on filter - self.matches = 0 - - # custom filter activation - self.activate_filter = False - - def set_filters(self, mask) -> None: - self.mask = mask - self.matches = 0 - self.activate_filter = True - self.invalidateFilter() - self.activate_filter = False - logger.info("{} filter matches found".format(self.matches)) - - def clear_filters(self) -> None: - self.mask = None - self.invalidateFilter() - - def filterAcceptsRow(self, row: int, parent) -> bool: - # check if self.activate_filter is enabled, else return True - if not self.activate_filter: - return True - # get the right index from the mask - matched = self.mask.iloc[row] - if matched: - self.matches += 1 - return matched - - -class ABDataFrameView(QtWidgets.QTableView): - """Base class for showing pandas dataframe objects as tables.""" - - ALL_FILTER = "All Files (*.*)" - CSV_FILTER = "CSV (*.csv);; All Files (*.*)" - TSV_FILTER = "TSV (*.tsv);; All Files (*.*)" - EXCEL_FILTER = "Excel (*.xlsx);; All Files (*.*)" - - def __init__(self, parent=None): - super().__init__(parent) - self.setVerticalScrollMode(QTableView.ScrollPerPixel) - self.setHorizontalScrollMode(QTableView.ScrollPerPixel) - - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) - - self.setWordWrap(True) - self.setAlternatingRowColors(True) - self.setSortingEnabled(True) - - self.horizontalHeader().setStretchLastSection(True) - self.horizontalHeader().setHighlightSections(False) - self.horizontalHeader().setDefaultAlignment(Qt.AlignLeft) - - self.verticalHeader().setDefaultSectionSize(22) # row height - self.verticalHeader().setVisible(False) - # Use a custom ViewOnly delegate by default. - # Can be overridden table-wide or per column in child classes. - self.setItemDelegate(delegates.ViewOnlyDelegate(self)) - - self.table_name = "LCA results" - # Initialize attributes which are set during the `sync` step. - # Creating (and typing) them here allows PyCharm to see them as - # valid attributes. - self.model: Optional[PandasModel] = None - self.proxy_model: Optional[ABSortProxyModel] = None - - def rowCount(self) -> int: - return 0 if self.model is None else self.model.rowCount() - - @Slot(name="updateProxyModel") - def update_proxy_model(self) -> None: - self.proxy_model = ABSortProxyModel(self) - self.proxy_model.setSourceModel(self.model) - self.proxy_model.setSortCaseSensitivity(Qt.CaseInsensitive) - self.setModel(self.proxy_model) - - @Slot(name="exportToClipboard") - def to_clipboard(self): - """Copy dataframe to clipboard""" - rows = list(range(self.model.rowCount())) - cols = list(range(self.model.columnCount())) - self.model.to_clipboard(rows, cols, include_header=True) - - def savefilepath( - self, default_file_name: str, caption: str = None, file_filter: str = None - ): - """Construct and return default path where data is stored - - Uses the application directory for AB - """ - safe_name = bd.utils.safe_filename(default_file_name, add_hash=False) - caption = caption or "Choose location to save lca results" - filepath, _ = QtWidgets.QFileDialog.getSaveFileName( - parent=self, - caption=caption, - dir=str(os.path.join(filesystem.get_project_path(), safe_name)), - filter=file_filter or self.ALL_FILTER, - ) - # getSaveFileName can now weirdly return Path objects. - return str(filepath) if filepath else filepath - - @Slot(name="exportToCsv") - def to_csv(self): - """Save the dataframe data to a CSV file.""" - filepath = self.savefilepath(self.table_name, file_filter=self.CSV_FILTER) - if filepath: - if not filepath.endswith(".csv"): - filepath += ".csv" - self.model.to_csv(filepath) - - @Slot(name="exportToExcel") - def to_excel(self, caption: str = None): - """Save the dataframe data to an excel file.""" - filepath = self.savefilepath( - self.table_name, caption, file_filter=self.EXCEL_FILTER - ) - if filepath: - if not filepath.endswith(".xlsx"): - filepath += ".xlsx" - self.model.to_excel(filepath) - - @Slot(QtGui.QKeyEvent, name="copyEvent") - def keyPressEvent(self, e): - """Allow user to copy selected data from the table - - NOTE: by default, the table headers (column names) are also copied. - """ - if e.modifiers() & Qt.ControlModifier: - # Should we include headers? - headers = e.modifiers() & Qt.ShiftModifier - if e.key() == Qt.Key_C: # copy - selection = [ - self.model.proxy_to_source(p) for p in self.selectedIndexes() - ] - rows = [index.row() for index in selection] - columns = [index.column() for index in selection] - rows = sorted(set(rows), key=rows.index) - columns = sorted(set(columns), key=columns.index) - self.model.to_clipboard(rows, columns, headers) - - -class ABFilterableDataFrameView(ABDataFrameView): - """Filterable base class for showing pandas dataframe objects as tables. - - To use this table, the following MUST be set in the table model: - - self.filterable_columns: dict - --> these columns are available for filtering - --> key is column name, value is column index - - To use this table, the following MUST be set in the table view: - - self.header.column_indices = list(self.model.filterable_columns.values()) - --> If not set, no filter buttons will appear. - --> Probably wise to set in a `if isinstance(self.model.filterable_columns, dict):` - --> This variable must be set any time the columns of the table change - - To use this table, the following can be set in the table model: - - self.different_column_types: dict - --> these columns require a different filter type than 'str' - --> e.g. self.different_column_types = {'col_name': 'num'} - """ - - FILTER_TYPES = { - "str": [ - "contains", - "does not contain", - "equals", - "does not equal", - "starts with", - "does not start with", - "ends with", - "does not end with", - ], - "str_tt": [ - "values in the column contain", - "values in the column do not contain", - "values in the column equal", - "values in the column do not equal", - "values in the column start with", - "values in the column do not start with", - "values in the column end with", - "values in the column do not end with", - ], - "num": ["=", "!=", ">=", "<=", "<= x <="], - "num_tt": [ - "values in the column equal", - "values in the column do not equal", - "values in the column are greater than or equal to", - "values in the column are smaller than or equal to", - "values in the column are between", - ], - } - - def __init__(self, parent=None): - super().__init__(parent) - - self.header = CustomHeader() - self.setHorizontalHeader(self.header) - - self.filters = None - self.different_column_types = {} - self.header.clicked.connect(self.header_filter_button_clicked) - self.selected_column = 0 - - # quick-filter setup: - self.prev_quick_filter = {} - self.debounce_quick_filter = QTimer() - self.debounce_quick_filter.setInterval(300) - self.debounce_quick_filter.setSingleShot(True) - self.debounce_quick_filter.timeout.connect(self.quick_filter) - - def header_filter_button_clicked(self, column: int, button: str) -> None: - self.selected_column = column - # this function is separate from the context menu in case we want to add right-click options later - if button == "LeftButton": - self.header_context_menu() - - def header_context_menu(self) -> None: - menu = QtWidgets.QMenu(self) - menu.setToolTipsVisible(True) - - col_type = self.model.different_column_types.get( - {v: k for k, v in self.model.filterable_columns.items()}[ - self.selected_column - ], - "str", - ) - - # quick-filter bar - self.input_line = QtWidgets.QLineEdit() - self.input_line.setFocusPolicy(Qt.StrongFocus) - if col_type == "num": - self.input_line.setValidator(QtGui.QDoubleValidator()) - search = QtWidgets.QToolButton() - search.setIcon(qicons.search) - search.clicked.connect(menu.close) - quick_filter_layout = QtWidgets.QHBoxLayout() - quick_filter_layout.addWidget(self.input_line) - quick_filter_layout.addWidget(search) - quick_filter_widget = QtWidgets.QWidget() - quick_filter_widget.setLayout(quick_filter_layout) - quick_filter_widget.setToolTip( - "Filter this column on the input,\n" - "press 'enter' or the search button to filter" - ) - # write previous filter to the quick-filter input if we have one - if prev_filter := self.prev_quick_filter.get(self.selected_column, False): - self.input_line.setText(prev_filter[1]) - else: - self.input_line.setPlaceholderText("Quick filter ...") - self.input_line.textChanged.connect(self.debounce_quick_filter.start) - self.input_line.returnPressed.connect(menu.close) - QAline = QtWidgets.QWidgetAction(self) - QAline.setDefaultWidget(quick_filter_widget) - menu.addAction(QAline) - - # More filters submenu - mf_menu = QtWidgets.QMenu(menu) - mf_menu.setToolTipsVisible(True) - mf_menu.setIcon(qicons.filter) - mf_menu.setTitle("More filters") - filter_actions = [] - for i, f in enumerate(self.FILTER_TYPES[col_type]): - fa = QtWidgets.QAction(text=f) - fa.setToolTip(self.FILTER_TYPES[col_type + "_tt"][i]) - fa.triggered.connect(self.simple_filter_dialog) - filter_actions.append(fa) - for fa in filter_actions: - mf_menu.addAction(fa) - menu.addMenu(mf_menu) - # edit filters main menu - filter_man = QtWidgets.QAction(qicons.edit, "Manage filters") - filter_man.triggered.connect(self.filter_manager_dialog) - filter_man.setToolTip("Open the filter management menu") - menu.addAction(filter_man) - # delete column filters option - col_del = QtWidgets.QAction(qicons.delete, "Remove column filters") - col_del.triggered.connect(self.reset_column_filters) - col_del.setToolTip("Remove all filters on this column") - menu.addAction(col_del) - col_del.setEnabled(False) - if isinstance(self.filters, dict) and self.filters.get( - self.selected_column, False - ): - col_del.setEnabled(True) - # delete all filters option - all_del = QtWidgets.QAction(qicons.delete, "Remove all filters") - all_del.triggered.connect(self.reset_filters) - all_del.setToolTip("Remove all filters in this table") - menu.addAction(all_del) - all_del.setEnabled(False) - if isinstance(self.filters, dict): - all_del.setEnabled(True) - - # Show existing filters for column - if isinstance(self.filters, dict) and self.filters.get( - self.selected_column, False - ): - menu.addSeparator() - active_filters_label = QtWidgets.QAction( - qicons.filter, "Active column filters:" - ) - active_filters_label.setEnabled(False) - menu.addAction(active_filters_label) - active_filters = [] - for filter_data in self.filters[self.selected_column]["filters"]: - if filter_data[0] == "<= x <=": - q = " and ".join(filter_data[1]) - else: - q = filter_data[1] - filter_str = ": ".join([filter_data[0], q]) - f = QtWidgets.QAction(text=filter_str) - f.setEnabled(False) - active_filters.append(f) - for f in active_filters: - menu.addAction(f) - - self.input_line.setFocus() - loc = self.header.event_pos - menu.exec_(self.mapToGlobal(loc)) - - @Slot(name="updateProxyModel") - def update_proxy_model(self) -> None: - self.proxy_model = ABMultiColumnSortProxyModel(self) - self.proxy_model.setSourceModel(self.model) - self.proxy_model.setSortCaseSensitivity(Qt.CaseInsensitive) - self.setModel(self.proxy_model) - - def quick_filter(self) -> None: - # remove weird whitespace from input - query = ( - self.input_line.text().translate(str.maketrans("", "", "\n\t\r")).strip() - ) - - # convert to filter - col_name = {v: k for k, v in self.model.filterable_columns.items()}[ - self.selected_column - ] - if self.model.different_column_types.get(col_name): - # column is type 'num' - filt = ("=", query) - else: - # column is type 'str' - filt = ("contains", query, False) - # check if quick filter exists for this col, if so; remove from self.filters - if prev_filter := self.prev_quick_filter.get(self.selected_column, False): - self.filters[self.selected_column]["filters"].remove(prev_filter) - - # place the filter in self.prev_quick_filter for next quick filter on this column - self.prev_quick_filter[self.selected_column] = filt - - # apply the right filters - if query != "": - # the query is not empty, add it to the filters and apply them - self.add_filter(filt) - self.apply_filters() - elif len(self.filters[self.selected_column]["filters"]) > 0: - # the query is empty, but there are still filters for this column, so apply the filters - self.apply_filters() - else: - # the query is empty, and there are no more filters for this column, reset this filter. - self.reset_column_filters() - - def filter_manager_dialog(self) -> None: - # get right data - column_names = self.model.filterable_columns - - # show dialog - dialog = FilterManagerDialog( - column_names=column_names, - filters=self.filters, - filter_types=self.FILTER_TYPES, - selected_column=self.selected_column, - column_types=self.model.different_column_types, - ) - if dialog.exec_() == FilterManagerDialog.Accepted: - # set the filters - filters = dialog.get_filters - if filters != self.filters: - # the filters returned from the dialog are different, actually apply the filters - rm = [] - for col, qf in self.prev_quick_filter.items(): - # check if quickfilters exist for these columns, otherwise remove them - if ( - filters.get(col, False) and qf not in filters[col]["filters"] - ) or not filters.get(col, False): - rm.append(col) - for col in rm: - self.prev_quick_filter.pop(col) - self.write_filters(filters) - self.apply_filters() - - def simple_filter_dialog(self, preset_type: str = None) -> None: - if not preset_type: - preset_type = self.sender().text() - - # get right data - column_name = {v: k for k, v in self.model.filterable_columns.items()}[ - self.selected_column - ] - col_type = self.model.different_column_types.get(column_name, "str") - - # show dialog - dialog = SimpleFilterDialog( - column_name=column_name, - filter_types=self.FILTER_TYPES, - column_type=col_type, - preset_type=preset_type, - ) - if dialog.exec_() == SimpleFilterDialog.Accepted: - new_filter = dialog.get_filter - # add the filter to existing filters - if new_filter: - self.add_filter(new_filter) - self.apply_filters() - - def add_filter(self, new_filter: tuple) -> None: - """Add a single filter to self.filters.""" - if isinstance(self.filters, dict): - # filters exist - all_filters = self.filters - if all_filters.get(self.selected_column, False): - # filters exist for this column - all_filters[self.selected_column]["filters"].append(new_filter) - if ( - not all_filters[self.selected_column].get("mode", False) - and len(all_filters[self.selected_column]["filters"]) > 1 - ): - # a mode does not exist, but there are multiple filters - all_filters[self.selected_column]["mode"] = "OR" - else: - # filters don't yet exist for this column: - all_filters[self.selected_column] = {"filters": [new_filter]} - else: - # no filters exist - all_filters = { - self.selected_column: {"filters": [new_filter]}, - "mode": "AND", - } - - self.write_filters(all_filters) - - def write_filters(self, filters: dict) -> None: - self.filters = filters - - def apply_filters(self) -> None: - if self.filters: - QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - # only allow filters that are for columns that may be filtered on - filters = { - k: v - for k, v in self.filters.items() - if k in list(self.model.filterable_columns.values()) + ["mode"] - } - self.proxy_model.set_filters(self.model.get_filter_mask(filters)) - self.header.has_active_filters = list(filters.keys()) - QtWidgets.QApplication.restoreOverrideCursor() - else: - self.reset_filters() - - def reset_column_filters(self) -> None: - """Reset all filters for this column.""" - QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - f = self.filters - if f.get(self.selected_column, False): - f.pop(self.selected_column) - if self.prev_quick_filter.get(self.selected_column, False): - self.prev_quick_filter.pop(self.selected_column) - self.write_filters(f) - if len(self.filters) == 1 and self.filters.get("mode"): - # the only thing in filters remaining is the mode --> there are no filters - self.reset_filters() - else: - self.header.has_active_filters = list(self.filters.keys()) - self.apply_filters() - QtWidgets.QApplication.restoreOverrideCursor() - - def reset_filters(self) -> None: - """Reset all filters for this entire table.""" - QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - self.write_filters(None) - self.header.has_active_filters = [] - self.prev_quick_filter = {} - self.proxy_model.clear_filters() - QtWidgets.QApplication.restoreOverrideCursor() - - -class LCAResultsModel(PandasModel): - def sync(self, df): - self._dataframe = df.replace(np.nan, "", regex=True) - self.updated.emit() - - -class LCAResultsTable(ABDataFrameView): - def __init__(self, parent=None): - super().__init__(parent) - self.model = LCAResultsModel(parent=self) - self.model.updated.connect(self.update_proxy_model) - - -class InventoryModel(PandasModel): - def sync(self, df): - self._dataframe = df - # set the visible columns - self.filterable_columns = { - col: i for i, col in enumerate(self._dataframe.columns.to_list()) - } - # set the columns te be defined as num (all except the first five for both biopshere and technosphere - self.different_column_types = { - col: "num" - for i, col in enumerate(self._dataframe.columns.to_list()) - if i >= 5 - } - self.updated.emit() - - -class InventoryTable(ABFilterableDataFrameView): - def __init__(self, parent=None): - super().__init__(parent) - self.horizontalHeader().setStretchLastSection(True) - - self.model = InventoryModel(parent=self) - self.model.updated.connect(self.update_proxy_model) - self.model.updated.connect(self.update_filter_data) - # below variables are required for switching between technosphere and biosphere tables - self.showing = None - self.filters_tec = None - self.filters_bio = None - - def update_filter_data(self) -> None: - if self.showing == "technosphere": - self.filters = self.filters_tec - else: - self.filters = self.filters_bio - - # update the column header indices - if isinstance(self.model.filterable_columns, dict): - self.header.column_indices = list(self.model.filterable_columns.values()) - # apply the existing filters - self.apply_filters() - - def write_filters(self, filters: dict) -> None: - if self.showing == "technosphere": - self.filters_tec = filters - else: - self.filters_bio = filters - self.filters = filters - - -class ContributionModel(PandasModel): - def sync(self, df, unit="relative share"): - - if "unit" in df.columns: - # overwrite the unit col with 'relative share' if looking at relative results (except 3 'total' and 'rest' rows) - df["unit"] = [""] * 3 + [unit] * (len(df) - 3) - - # drop any rows where all numbers are 0 - self._dataframe = df.loc[~(df.select_dtypes(include=np.number) == 0).all(axis=1)] - self.updated.emit() - - -class ContributionTable(ABDataFrameView): - def __init__(self, parent=None): - super().__init__(parent) - self.model = ContributionModel(parent=self) - self.model.updated.connect(self.update_proxy_model) diff --git a/activity_browser/app/pages/lca_results/tree_navigator.py b/activity_browser/app/pages/lca_results/tree_navigator.py deleted file mode 100644 index d3d2ba158..000000000 --- a/activity_browser/app/pages/lca_results/tree_navigator.py +++ /dev/null @@ -1,506 +0,0 @@ -import json -import time -from typing import List, Optional -from loguru import logger - -import bw2calc as bc -import bw2data as bd -from qtpy import QtWidgets -from qtpy.QtCore import Slot -from qtpy.QtWidgets import QComboBox -from bw_graph_tools.graph_traversal import ( - SameNodeEachVisitGraphTraversal, - SameNodeEachVisitTaggedGraphTraversal, - GraphTraversalSettings, - TaggedGraphTraversalSettings, -) -from bw_graph_tools.graph_traversal.graph_objects import ( - Node as GraphNode, - Edge as GraphEdge, - GroupedNodes as GraphGroupedNodes, -) -from bw2data.backends import ActivityDataset - -from activity_browser import app -from activity_browser.bwutils.filesystem import get_package_path -from activity_browser.bwutils.commontasks import identify_activity_type -from activity_browser.ui import widgets - - -class SmallComboBox(QtWidgets.QComboBox): - """A small combo box that does not expand to fill the available space.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) - self.setMinimumWidth(100) - self.setMaximumWidth(200) - self.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContentsOnFirstShow) - - -class TreeNavigatorWidget(widgets.ABAbstractNavigator): - HELP_TEXT = """ - LCA Dynamic Tree Navigator: - - Red flows: Impacts - Green flows: Avoided impacts - - """ - HTML_FILE = str(get_package_path() / "static" / "tree_navigator.html") - - def __init__(self, cs_name, parent=None): - super().__init__(parent, css_file="tree_navigator.css") - - self.cache = {} # we cache the calculated data to improve responsiveness - self.parent = parent - self.has_scenarios = self.parent.has_scenarios - self.cs = cs_name - self.selected_db = None - self.has_rendered_once = False - self.func_units = [] - self.methods = [] - self.scenarios = [] - self.graph = Graph() - - # Additional Qt objects - self.scenario_label = QtWidgets.QLabel("Scenario: ") - self.func_unit_cb = SmallComboBox() - self.method_cb = SmallComboBox() - self.scenario_cb = SmallComboBox() - self.tag_cb = widgets.CheckableComboBox() - self.cutoff_sb = QtWidgets.QDoubleSpinBox() - self.max_calc_sb = QtWidgets.QDoubleSpinBox() - self.button_calculate = QtWidgets.QPushButton("Calculate") - self.layout = QtWidgets.QVBoxLayout() - - # graph - self.draw_graph() - self.construct_layout() - self.connect_signals() - - @Slot(name="loadFinishedHandler") - def load_finished_handler(self) -> None: - self.send_json() - - def connect_signals(self): - super().connect_signals() - self.button_calculate.clicked.connect(self.new_tree) - app.signals.database_selected.connect(self.set_database) - # checkboxes - self.func_unit_cb.currentIndexChanged.connect(self.new_tree) - self.method_cb.currentIndexChanged.connect(self.new_tree) - self.scenario_cb.currentIndexChanged.connect(self.new_tree) - self.tag_cb.onHidePopup.connect(self.new_tree) - self.bridge.update_graph.connect(self.update_graph) - - def construct_layout(self) -> None: - """Layout of Sankey Navigator""" - super().construct_layout() - self.label_help.setVisible(False) - - # Layout Reference Flows and Impact Categories - grid_lay = QtWidgets.QGridLayout() - grid_lay.addWidget(QtWidgets.QLabel("Reference flow: "), 0, 0) - - grid_lay.addWidget(self.scenario_label, 1, 0) - grid_lay.addWidget(QtWidgets.QLabel("Impact indicator: "), 2, 0) - grid_lay.addWidget(QtWidgets.QLabel("Tag System: "), 2, 2) - - self.update_calculation_setup() - - grid_lay.addWidget(self.func_unit_cb, 0, 1, 1, 3) - grid_lay.addWidget(self.scenario_cb, 1, 1) - grid_lay.addWidget(self.method_cb, 2, 1) - grid_lay.addWidget(self.tag_cb, 2, 3) - - # cut-off - grid_lay.addWidget(QtWidgets.QLabel("Cutoff: "), 2, 4) - self.cutoff_sb.setRange(0.0, 1.0) - self.cutoff_sb.setSingleStep(0.01) - self.cutoff_sb.setDecimals(3) - self.cutoff_sb.setValue(0.05) - self.cutoff_sb.setKeyboardTracking(False) - grid_lay.addWidget(self.cutoff_sb, 2, 5) - - # max-iterations of graph traversal - grid_lay.addWidget(QtWidgets.QLabel("Calculation depth: "), 2, 6) - self.max_calc_sb.setRange(1, 2000) - self.max_calc_sb.setSingleStep(50) - self.max_calc_sb.setDecimals(0) - self.max_calc_sb.setValue(250) - self.max_calc_sb.setKeyboardTracking(False) - grid_lay.addWidget(self.max_calc_sb, 2, 7) - - grid_lay.setColumnStretch(6, 1) - hlay = QtWidgets.QHBoxLayout() - hlay.addLayout(grid_lay) - - # Controls Layout - # hl_controls = QtWidgets.QHBoxLayout() - grid_lay.addWidget(self.button_calculate, 0, 5) - grid_lay.addWidget(self.button_refresh, 0, 6) - grid_lay.addWidget(self.button_toggle_help, 0, 7) - # hl_controls.addStretch(1) - - # Layout - self.layout.addLayout(hlay) - # self.layout.addLayout(hl_controls) - self.layout.addWidget(self.label_help) - self.layout.addWidget(self.view) - self.setLayout(self.layout) - - def get_scenario_labels(self) -> List[str]: - """Get scenario labels if scenario is used.""" - return self.parent.mlca.scenario_names if self.has_scenarios else [] - - def configure_scenario(self): - """Determine if scenario Qt widgets are visible or not and retrieve - scenario labels for the selection drop-down box. - """ - self.scenario_cb.setVisible(self.has_scenarios) - self.scenario_label.setVisible(self.has_scenarios) - if self.has_scenarios: - self.scenarios = self.get_scenario_labels() - self.update_combobox(self.scenario_cb, self.scenarios) - - @staticmethod - def update_combobox(box: QComboBox, labels: List[str]) -> None: - """Update the combobox menu.""" - box.blockSignals(True) - box.clear() - box.insertItems(0, labels) - box.blockSignals(False) - - def update_calculation_setup(self, cs_name=None) -> None: - """Update Calculation Setup, reference flows and impact categories, and dropdown menus.""" - # block signals - block_signals = [self.func_unit_cb, self.method_cb, self.tag_cb] - for b in block_signals: - b.blockSignals(True) - - self.cs = cs_name or self.cs - self.func_units = [ - {bd.get_activity(k): v for k, v in fu.items()} - for fu in bd.calculation_setups[self.cs]["inv"] - ] - self.methods = bd.calculation_setups[self.cs]["ia"] - self.func_unit_cb.clear() - fu_acts = [list(fu.keys())[0] for fu in self.func_units] - self.func_unit_cb.addItems( - [f"{repr(a)} | {a._data.get('database')}" for a in fu_acts] - ) - self.configure_scenario() - self.method_cb.clear() - self.method_cb.addItems([repr(m) for m in self.methods]) - - # tags - self.tag_cb.clear() - self.tag_cb.addItems([]) - - # unblock signals - for b in block_signals: - b.blockSignals(False) - - def new_tree(self) -> None: - """(re)-generate the tree diagram.""" - demand_index = self.func_unit_cb.currentIndex() - method_index = self.method_cb.currentIndex() - self.update_tree( - self.func_units[demand_index], - self.methods[method_index], - demand_index=demand_index, - method_index=method_index, - scenario_index=self.scenario_cb.currentIndex() if self.has_scenarios else None, - scenario_lca=bool(self.has_scenarios), - cut_off=self.cutoff_sb.value(), - max_calc=int(self.max_calc_sb.value()), - tags=self.tag_cb.currentData(), - ) - - def update_tree( - self, - demand: dict, - method: tuple, - demand_index: int = None, - method_index: int = None, - scenario_index: int = None, - scenario_lca: bool = False, - cut_off=0.05, - max_calc=100, - tags=None, - ) -> None: - """Calculate LCA, do graph traversal, get JSON graph data for this, and send to javascript.""" - - # the cache key consists of demand/method/scenario indices (index of item in the relevant tables), - # the cutoff, max_calc. - # together, these are unique. - cache_key = ( - demand_index, - method_index, - scenario_index, - cut_off, - max_calc, - str(tags), - ) - if data := self.cache.get(cache_key, False): - # this Graph is already cached, generate the tree with Graph cached data - logger.debug(f"CACHED tree for: {demand}, {method}, key: {cache_key}") - self.graph.new_graph(data) - self.has_rendered_once = bool(self.graph.json_data) - self.send_json() - return - - start = time.time() - logger.debug(f"CALCULATE tree for: {demand}, {method}, key: {cache_key}") - - try: - if scenario_lca: - self.parent.mlca.update_lca_calculation_for_sankey( - scenario_index, demand, method_index - ) - - if not hasattr(self, "cached_lca"): - fu, data_objs, _ = bd.prepare_lca_inputs(demand=demand, method=method) - self.cached_lca = bc.LCA(demand=fu, data_objs=data_objs) - self.cached_lca.lci(factorize=True) - self.cached_lca.lcia() - if tags: - data = SameNodeEachVisitTaggedGraphTraversal( - lca=self.cached_lca, - settings=TaggedGraphTraversalSettings( - tags=tags, cutoff=cut_off, max_calc=max_calc - ), - ) - else: - data = SameNodeEachVisitGraphTraversal( - lca=self.cached_lca, - settings=GraphTraversalSettings( - cutoff=cut_off, max_calc=max_calc - ), - ) - data.traverse(depth=2) - - # store the metadata from this calculation - data.metadata = { - "unit": bd.methods[method]["unit"], - } - except (ValueError, ZeroDivisionError) as e: - QtWidgets.QMessageBox.information( - None, "Nonsensical numeric result.", str(e) - ) - logger.debug(f"Completed graph traversal ({round(time.time() - start, 2)} seconds") - - # cache the generated Graph data - self.cache[cache_key] = data - - # generate the new Graph - self.graph.new_graph(data) - self.has_rendered_once = bool(self.graph.json_data) - self.send_json() - - def set_database(self, name): - """Saves the currently selected database for graphing a random activity""" - self.selected_db = name - - def random_graph(self) -> None: - """Show graph for a random activity in the currently loaded database.""" - if self.selected_db: - method = bd.methods.random() - act = bd.Database(self.selected_db).random() - demand = {act: 1.0} - self.update_tree(demand, method) - else: - QtWidgets.QMessageBox.information( - None, "Not possible.", "Please load a database first." - ) - - @Slot(object, name="update_graph") - def update_graph(self, click_dict: dict) -> None: - """Update the graph with the specified JSON data.""" - traversed = self.graph.state_graph.traverse_from_node(click_dict["id"]) - if not traversed: - # nothing has changed - return - self.graph.json_data = Graph.get_json_data(self.graph.state_graph) - self.send_json() - - -class Graph(widgets.ABAbstractGraph): - """ - Python side representation of the graph. - Functionality for graph navigation (e.g. adding and removing nodes). - A JSON representation of the graph (edges and nodes) enables its use in javascript/html/css. - """ - - def __init__(self): - super().__init__() - self.state_graph: Optional["SameNodeEachVisitGraphTraversal"] = None - - @staticmethod - def get_data_from_state_graph(state_graph: "SameNodeEachVisitGraphTraversal"): - return { - "nodes": state_graph.nodes, - "edges": state_graph.edges, - "flows": state_graph.flows, - "calculation_count": state_graph.calculation_count.value, - "metadata": state_graph.metadata, - } - - def new_graph(self, state_graph: "SameNodeEachVisitGraphTraversal"): - self.state_graph = state_graph - self.json_data = Graph.get_json_data(state_graph) - self.update() - - @staticmethod - def get_json_data(state_graph: "SameNodeEachVisitGraphTraversal") -> str: - """Transform graph traversal output to JSON data. - - We use the [dagre](https://github.com/dagrejs/dagre) javascript library for rendering directed graphs. We need to provide the following: - - ```python - { - 'max_impact': float, # Total LCA score, - 'title': str, # Graph title - 'edges': [{ - 'source_id': int, # Unique ID of producer of material or energy in graph - 'target_id': int, # Unique ID of consumer of material or energy in graph - 'weight': float, # In graph units, relative to `max_edge_width` - 'label': str, # HTML label - 'product': str, # The label of the flowing material or energy - 'class': str, # "benefit" or "impact"; controls styling - 'label': str, # HTML label - 'toottip': str, # HTML tooltip - }], - 'nodes': [{ - 'direct_emissions_score_normalized': float, # Fraction of total LCA score from direct emissions - 'product': str, # Reference product label, if any - 'location': str, # Location, if any - 'id': int, # Graph traversal ID - 'database_id': int, # Node ID in SQLite database - 'database': str, # Database name - 'class': str, # Enumerated set of class label strings - 'label': str, # HTML label including name and location - 'toottip': str, # HTML tooltip - }], - } - - ``` - - """ - lca_score = state_graph.lca.score - lcia_unit = state_graph.metadata["unit"] - demand = state_graph.lca.demand - - def convert_edge_to_json( - edge: GraphEdge, - nodes: dict[int, GraphNode], - total_score: float, - lcia_unit: str, - max_edge_width: int = 40, - ) -> dict: - cum_score = nodes[edge.producer_unique_id].cumulative_score - node = nodes[edge.producer_unique_id] - if isinstance(node, GraphGroupedNodes): - unit = "" - else: - unit = bd.get_node( - id=nodes[edge.producer_unique_id].reference_product_datapackage_id - ).get("unit", "(unknown)") - return { - "source_id": edge.producer_unique_id, - "target_id": edge.consumer_unique_id, - "amount": edge.amount, - "weight": abs(cum_score / total_score) * max_edge_width, - "label": f"{round(cum_score, 3)} {lcia_unit}", - "class": "benefit" if cum_score < 0 else "impact", - "tooltip": f"{round(cum_score, 3)} {lcia_unit} ({edge.amount:.2g} {unit})", - } - - def convert_node_to_json( - graph_node: GraphNode, - total_score: float, - fu: dict, - lcia_unit: str, - max_name_length: int = 20, - ) -> dict: - expanded = graph_node.unique_id in state_graph.visited_nodes - if isinstance(graph_node, GraphGroupedNodes): - data = { - "direct_emissions_score_normalized": graph_node.direct_emissions_score - / (total_score or 1), - "direct_emissions_score": graph_node.direct_emissions_score, - "cumulative_score": graph_node.cumulative_score, - "cumulative_score_normalized": graph_node.cumulative_score - / (total_score or 1), - "product": "", - "location": "", - "id": graph_node.unique_id, - "database_id": "", - "database": "", - "class": "", - "name": graph_node.label, - "expanded": expanded, - } - else: - db_node = bd.get_node(id=graph_node.activity_datapackage_id) - data = { - "direct_emissions_score_normalized": graph_node.direct_emissions_score - / (total_score or 1), - "direct_emissions_score": graph_node.direct_emissions_score, - "cumulative_score": graph_node.cumulative_score, - "cumulative_score_normalized": graph_node.cumulative_score - / (total_score or 1), - "product": db_node.get("reference product", ""), - "location": db_node.get("location", "(unknown)"), - "id": graph_node.unique_id, - "database_id": graph_node.activity_datapackage_id, - "database": db_node["database"], - "class": ( - "demand" - if graph_node.activity_datapackage_id in fu - else identify_activity_type(db_node) - ), - "name": db_node.get("name", "(unnamed)"), - "expanded": expanded, - } - frac_dir_score = round(data["direct_emissions_score_normalized"] * 100, 2) - dir_score = round(data["direct_emissions_score"], 3) - frac_cum_score = round(data["cumulative_score_normalized"] * 100, 2) - cum_score = round(data["cumulative_score"], 3) - if isinstance(graph_node, GraphGroupedNodes): - data["label"] = data["name"] - else: - data["label"] = "{}\n{}\n{}".format( - db_node["name"][:max_name_length], data["location"], frac_dir_score - ) - data[ - "tooltip" - ] = f""" - {data['name']} -
Individual impact: {dir_score} {lcia_unit} ({frac_dir_score}%) -
Cumulative impact: {cum_score} {lcia_unit} ({frac_cum_score}%) -
Expanded: {expanded} - """ - return data - - json_data = { - "nodes": [ - convert_node_to_json(node, lca_score, demand, lcia_unit) - for idx, node in state_graph.nodes.items() - if idx != -1 - ], - "edges": [ - convert_edge_to_json(edge, state_graph.nodes, lca_score, lcia_unit) - for edge in state_graph.edges - if edge.producer_index != -1 and edge.consumer_index != -1 - ], - "title": None, - } - - return json.dumps(json_data) - - -def id_to_key(id): - if isinstance(id, tuple): - return id - return ActivityDataset.get_by_id(id).database, ActivityDataset.get_by_id(id).code diff --git a/activity_browser/app/pages/metadatastore.py b/activity_browser/app/pages/metadatastore.py deleted file mode 100644 index b1336b260..000000000 --- a/activity_browser/app/pages/metadatastore.py +++ /dev/null @@ -1,36 +0,0 @@ -from qtpy import QtWidgets -from loguru import logger - -from activity_browser.ui import widgets, delegates, core -from activity_browser.app import metadata, signals - - -class MetaDataStorePage(QtWidgets.QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.setObjectName("MetaDataStorePage") - - self.model = core.ABTreeModel(metadata.dataframe, self, chunk_size=50) - self.view = MDSView(self) - self.view.setModel(self.model) - - self.build_layout() - self.connect_signals() - - def connect_signals(self): - signals.metadata.synced.connect(self.sync) - - def sync(self): - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - self.model.set_dataframe(metadata.dataframe) - - def build_layout(self): - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.view) - self.setLayout(layout) - - -class MDSView(widgets.ABTreeView): - def __init__(self, parent=None): - super().__init__(parent) - self.setItemDelegate(delegates.StringDelegate(self)) diff --git a/activity_browser/app/pages/parameters/__init__.py b/activity_browser/app/pages/parameters/__init__.py deleted file mode 100644 index dd02adad4..000000000 --- a/activity_browser/app/pages/parameters/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .parameters import ParametersPage - diff --git a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py deleted file mode 100644 index be4a2663e..000000000 --- a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py +++ /dev/null @@ -1,296 +0,0 @@ -from loguru import logger - -from qtpy import QtWidgets, QtCore -from qtpy.QtCore import Qt - -import pandas as pd -import bw2data as bd -from bw2data.parameters import ParameterizedExchange -from bw2data.backends import ExchangeDataset - -from activity_browser import app -from activity_browser.ui import widgets, icons, delegates, core -from activity_browser.bwutils.commontasks import database_is_locked -from activity_browser.bwutils.utils import Parameter - - -class ParameterizedExchangesSection(QtWidgets.QWidget): - """ - A widget section that displays all parameterized exchanges in the current project. - - Attributes: - model (ParameterizedExchangesModel): The model containing the data for the exchanges. - view (ParameterizedExchangesView): The view displaying the exchanges. - """ - - def __init__(self, parent=None): - """ - Initializes the ParameterizedExchangesSection widget. - - Args: - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(parent) - self._populate_later_flag = False - - # Parameterized exchanges table view - self.model = ParameterizedExchangesModel(parent=self) - self.view = ParameterizedExchangesView() - self.view.setModel(self.model) - - self.build_layout() - self.connect_signals() - - def build_layout(self): - """ - Builds the layout of the widget. - """ - layout = QtWidgets.QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.view) - self.setLayout(layout) - - def connect_signals(self): - """ - Connects signals to their respective slots. - """ - app.signals.metadata.synced.connect(self.syncLater) - app.signals.parameter.changed.connect(self.syncLater) - app.signals.parameter.recalculated.connect(self.syncLater) - app.signals.parameter.deleted.connect(self.syncLater) - app.signals.project.changed.connect(self.syncLater) - app.signals.meta.databases_changed.connect(self.syncLater) - - def syncLater(self): - """ - Schedules a sync operation to be performed later. - """ - - def slot(): - self._populate_later_flag = False - self.sync() - self.thread().eventDispatcher().awake.disconnect(slot) - - if self._populate_later_flag: - return - - self._populate_later_flag = True - self.thread().eventDispatcher().awake.connect(slot) - - def sync(self): - """ - Synchronizes the widget with the current state of parameterized exchanges. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - df = self.build_exchanges_df() - self.model.set_dataframe(df) - - def build_exchanges_df(self) -> pd.DataFrame: - """ - Builds a DataFrame from all parameterized exchanges in the project. - - Returns: - pd.DataFrame: The DataFrame containing the parameterized exchanges data. - """ - translated = [] - - # Get all parameterized exchanges - for param_exc in ParameterizedExchange.select(): - try: - exchange = bd.Edge(document=ExchangeDataset.get_by_id(param_exc.exchange)) - - # Get keys for input and output - input_key = exchange.get("input") - output_key = exchange.get("output") - - # Get metadata from metadata store - input_meta = app.metadata.get_metadata([input_key], ["name", "unit", "location", "database", "product"]).iloc[0] - output_meta = app.metadata.get_metadata([output_key], ["name"]).iloc[0] - - row = { - "amount": exchange.get("amount"), - "unit": input_meta.get("unit"), - "from": input_meta.get("product") or input_meta.get("name"), - "to": output_meta.get("name"), - "database": input_meta.get("database"), - "formula": exchange.get("formula"), - "comment": exchange.get("comment"), - "uncertainty": exchange.get("uncertainty type"), - "_exchange": exchange, - "_output_key": output_key, - "_input_key": input_key, - } - translated.append(row) - except Exception as e: - # Skip if exchange can't be loaded - continue - - columns = ["amount", "unit", "from", "to", "database", "formula", "comment", "uncertainty", "_exchange", "_output_key", "_input_key"] - return pd.DataFrame(translated, columns=columns) - - -class ParameterizedExchangesView(widgets.ABTreeView): - """ - A view that displays parameterized exchanges in a tree structure. - - Attributes: - defaultColumnDelegates (dict): The default column delegates for the view. - """ - defaultColumnDelegates = { - "amount": delegates.FloatDelegate, - "unit": delegates.StringDelegate, - "product": delegates.StringDelegate, - "producer": delegates.StringDelegate, - "location": delegates.StringDelegate, - "database": delegates.StringDelegate, - "formula": delegates.NewFormulaDelegate, - "comment": delegates.StringDelegate, - "uncertainty": delegates.UncertaintyDelegate, - } - - class ContextMenu(QtWidgets.QMenu): - """ - A context menu for the ParameterizedExchangesView. - """ - def __init__(self, pos, view: "ParameterizedExchangesView"): - """ - Initializes the ContextMenu. - - Args: - pos: The position of the context menu. - view (ParameterizedExchangesView): The view displaying the exchanges. - """ - super().__init__(view) - - index = view.indexAt(pos) - if index.isValid() and not view.model().isBranchNode(index): - row = view.model().row(index) - if row is not None: - output_key = row.get("_output_key") - if output_key: - # Open activity action - open_action = app.actions.ActivityOpen.get_QAction([output_key]) - open_action.setText("Open activity") - self.addAction(open_action) - - -class ParameterizedExchangesModel(core.ABTreeModel): - """ - A model representing the data for parameterized exchanges. - """ - - def __init__(self, parent=None): - """ - Initializes the ParameterizedExchangesModel. - - Args: - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(df=pd.DataFrame(), parent=parent) - - def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: - """ - Sets the data for the given index. - - Args: - index (QtCore.QModelIndex): The index to set data for. - value: The value to set. - role (int): The role for which to set the data. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if role != Qt.ItemDataRole.EditRole: - return False - - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return False - - exchange = row.get("_exchange") - if exchange is None: - return False - - if column_name in ["amount", "formula", "comment"]: - if column_name == "formula" and not str(value).strip(): - # Remove formula if empty - app.actions.ExchangeFormulaRemove.run([exchange]) - return True - - app.actions.ExchangeModify.run(exchange, {column_name.lower(): value}) - return True - - return False - - def decorationData(self, index: QtCore.QModelIndex) -> any: - """ - Provides decoration data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide decoration data. - - Returns: - The decoration data for the index. - """ - column_name = self.column_name(index) - - if column_name == "amount": - formula = self.get(index, "formula") - if pd.isna(formula) or formula is None or formula == "": - return icons.qicons.edit - return icons.qicons.parameterized - - return None - - def indexEditable(self, index: QtCore.QModelIndex) -> bool: - """ - Returns whether the index is editable. - - Args: - index (QtCore.QModelIndex): The index to check. - - Returns: - bool: True if the index is editable, False otherwise. - """ - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return False - - # Check if database is locked - exchange = row.get("_exchange") - if exchange and database_is_locked(exchange.output["database"]): - return False - - # Allow editing for specific columns - if column_name in ["amount", "formula", "comment"]: - return True - - return False - - def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: - """ - Returns the parameters in scope of the exchange at the given index. - - Args: - index (QtCore.QModelIndex): The index to get scoped parameters for. - - Returns: - dict: The parameters in scope. - """ - from activity_browser.bwutils.commontasks import parameters_in_scope - - row = self.row(index) - if row is None: - return {} - - exchange = row.get("_exchange") - if exchange is None: - return {} - - return parameters_in_scope(node=exchange.output) - diff --git a/activity_browser/app/pages/parameters/parameters.py b/activity_browser/app/pages/parameters/parameters.py deleted file mode 100644 index 72c953230..000000000 --- a/activity_browser/app/pages/parameters/parameters.py +++ /dev/null @@ -1,65 +0,0 @@ -from qtpy import QtWidgets, QtCore - -from activity_browser.ui import widgets - -from .parameters_section import ParametersSection -from .parameterized_exchanges_section import ParameterizedExchangesSection - - -class ParametersPage(QtWidgets.QWidget): - """ - A widget that displays all parameters and parameterized exchanges in the current project. - - This page shows: - - Parameters section: A tree view of parameters organized by scope - - Parameterized exchanges section: A table of exchanges with formulas - """ - - def __init__(self, parent=None): - """ - Initializes the ParametersPage widget. - - Args: - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(parent) - - self.parameters_section = ParametersSection(self) - self.parameterized_exchanges_section = ParameterizedExchangesSection(self) - - self.build_layout() - - def build_layout(self): - """ - Builds the layout of the widget. - """ - layout = QtWidgets.QVBoxLayout() - layout.setContentsMargins(0, 3, 0, 0) - - # Add both sections in a splitter - splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical, self) - - # Parameters section - params_widget = QtWidgets.QWidget() - params_layout = QtWidgets.QVBoxLayout(params_widget) - params_layout.setContentsMargins(0, 0, 0, 0) - params_label = widgets.ABLabel.demiBold(" Parameters") - params_layout.addWidget(params_label) - params_layout.addWidget(widgets.ABHLine(self)) - params_layout.addWidget(self.parameters_section) - splitter.addWidget(params_widget) - - # Parameterized exchanges section - exchanges_widget = QtWidgets.QWidget() - exchanges_layout = QtWidgets.QVBoxLayout(exchanges_widget) - exchanges_layout.setContentsMargins(0, 0, 0, 0) - exchanges_label = widgets.ABLabel.demiBold(" Parameterized Exchanges") - exchanges_layout.addWidget(exchanges_label) - exchanges_layout.addWidget(widgets.ABHLine(self)) - exchanges_layout.addWidget(self.parameterized_exchanges_section) - splitter.addWidget(exchanges_widget) - - layout.addWidget(splitter) - self.setLayout(layout) - - diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py deleted file mode 100644 index aa6f3df5e..000000000 --- a/activity_browser/app/pages/parameters/parameters_section.py +++ /dev/null @@ -1,443 +0,0 @@ -from loguru import logger - -from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt - -import pandas as pd -import bw2data as bd -from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, Group - -from activity_browser import app -from activity_browser.ui import widgets, icons, delegates, core -from activity_browser.bwutils.commontasks import refresh_parameter, database_is_locked, parameters_in_scope -from activity_browser.bwutils.utils import Parameter - - -class ParametersSection(QtWidgets.QWidget): - """ - A widget section that displays all parameters in the current project. - - This section shows a tree view of parameters organized by scope: - - Project parameters - - Database parameters (grouped by database) - - Activity parameters (grouped by activity group) - - Attributes: - model (ProjectParametersModel): The model containing the data for the parameters. - view (ProjectParametersView): The view displaying the parameters. - """ - - def __init__(self, parent=None): - """ - Initializes the ParametersSection widget. - - Args: - parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(parent) - self._populate_later_flag = False - - # Parameters tree view - self.model = ProjectParametersModel(parent=self) - self.view = ProjectParametersView() - self.view.setSortingEnabled(False) - self.view.setUniformRowHeights(True) - - self.view.setModel(self.model) - - self.build_layout() - self.connect_signals() - - def build_layout(self): - """ - Builds the layout of the widget. - """ - layout = QtWidgets.QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.view) - self.setLayout(layout) - - def connect_signals(self): - """ - Connects signals to their respective slots. - """ - app.signals.metadata.synced.connect(self.syncLater) - app.signals.project.changed.connect(self.syncLater) - app.signals.meta.databases_changed.connect(self.syncLater) - app.signals.database.deleted.connect(self.syncLater) - - app.signals.parameter.changed.connect(self.syncLater) - app.signals.parameter.recalculated.connect(self.syncLater) - app.signals.parameter.deleted.connect(self.syncLater) - - def syncLater(self): - """ - Schedules a sync operation to be performed later. - """ - - def slot(): - self._populate_later_flag = False - self.sync() - self.thread().eventDispatcher().awake.disconnect(slot) - - if self._populate_later_flag: - return - - self._populate_later_flag = True - self.thread().eventDispatcher().awake.connect(slot) - - def sync(self): - """ - Synchronizes the widget with the current state of parameters. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - df = self.build_df() - self.model.set_dataframe(df, group=["_param_type", "_scope"]) - self.view.expandAll() - - self.view.resizeColumnToContents(1) - self.view.resizeColumnToContents(3) - self.view.resizeColumnToContents(4) - - def build_df(self) -> pd.DataFrame: - """ - Builds a DataFrame from all parameters in the project. - - Returns: - pd.DataFrame: The DataFrame containing the parameters data. - """ - translated = [] - - # Project parameters - for param in ProjectParameter.select(): - row = self._parameter_to_row(param) - translated.append(row) - - translated.append({ - "name": "New parameter...", - "_group": "project", - "_param_type": "project", - "_class": "new", - }) - - # Database parameters - db_params = DatabaseParameter.select() - for db_name in bd.databases.list: - - for param in db_params.where(DatabaseParameter.database == db_name): - row = self._parameter_to_row(param, db_name, db_name) - translated.append(row) - - if not database_is_locked(db_name): - translated.append({ - "name": "New parameter...", - "_scope": db_name, - "_database": db_name, - "_group": db_name, - "_param_type": "database", - "_class": "new", - }) - - # Activity parameters - act_params = ActivityParameter.select() - groups = Group.select() - non_act = ["project"] + bd.databases.list - - for group_name in [group.name for group in groups if group.name not in non_act]: - param = None - - for param in act_params.where(ActivityParameter.group == group_name): - row = self._parameter_to_row(param, f"Group: {group_name}", param.database) - translated.append(row) - - if param is None: - # No parameters in this group: broken group - translated.append({ - "name": "Broken parameter group", - "_scope": f"Group: {group_name}", - "_database": None, - "_group": group_name, - "_param_type": "activity", - "_class": "broken", - }) - continue - - if not database_is_locked(param.database): - translated.append({ - "name": "New parameter...", - "_scope": f"Group: {group_name}", - "_database": param.database, - "_group": group_name, - "_param_type": "activity", - "_class": "new", - }) - - columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_database", "_group", "_param_type", "_class"] - df = pd.DataFrame(translated, columns=columns) - return df - - def _parameter_to_row(self, param, scope_label: str = None, database: str = None) -> dict: - """ - Converts a parameter to a row dictionary. - - Args: - param: The parameter to convert (ProjectParameter, DatabaseParameter, or ActivityParameter). - scope_label: The label for the scope (e.g., "Current project", "Database: ecoinvent"). - database: The database name (None for project parameters). - - Returns: - dict: A dictionary representing the parameter row. - """ - data = param.dict - - # Create Parameter wrapper - if isinstance(param, ProjectParameter): - parameter = Parameter(param.name, "project", data.get("amount"), data, "project") - group = "project" - param_type = "project" - elif isinstance(param, DatabaseParameter): - parameter = Parameter(param.name, param.database, data.get("amount"), data, "database") - group = param.database - param_type = "database" - elif isinstance(param, ActivityParameter): - parameter = Parameter(param.name, param.group, data.get("amount"), data, "activity") - group = param.group - param_type = "activity" - else: - raise ValueError(f"Unknown parameter type: {type(param)}") - - row = { - "name": parameter.name, - "amount": parameter.amount, - "uncertainty": parameter.uncertainty, - "formula": data.get("formula"), - "comment": data.get("comment"), - "_param_type": param_type, - "_parameter": parameter, - "_scope": scope_label, - "_database": database, - "_group": group, - "_class": "instantiated", - } - - return row - - -class ProjectParametersView(widgets.ABTreeView): - """ - A view that displays the project parameters in a tree structure. - - Attributes: - defaultColumnDelegates (dict): The default column delegates for the view. - """ - defaultColumnDelegates = { - "amount": delegates.FloatDelegate, - "name": delegates.StringDelegate, - "formula": delegates.NewFormulaDelegate, - "comment": delegates.StringDelegate, - "uncertainty": delegates.UncertaintyDelegate, - } - - class ContextMenu(widgets.ABMenu): - """ - A context menu for the ProjectParametersView. - """ - menuSetup = [ - lambda m, p: m.add(app.actions.ParameterDelete, p.selected_parameters(), - text="Delete parameter(s)", - enable=(all([p.deletable for p in p.selected_parameters()]) - and len(p.selected_parameters()) > 0) - and all([not database_is_locked(p.data['database']) - for p in p.selected_parameters() - if p.param_type != "project" - ]) - ), - lambda m, p: m.add(app.actions.ParameterGroupDelete, p.selected_groups(), - text="Delete parameter group(s)", - enable=(len(p.selected_groups()) > 0 - and all([g not in ["project"] + list(bd.databases) - for g in p.selected_groups()]) - and all([not database_is_locked(p.data['database']) - for p in p.selected_parameters() - if p.param_type != "project" - ]) - ) - ), - ] - - def selected_parameters(self): - """ - Returns a list of selected parameters in the view. - - Returns: - list: A list of selected Parameter objects. - """ - selected = [] - for index in self.selectedIndexes(): - parameter = self.model().get(index, "_parameter") - if parameter is not None and not pd.isna(parameter) and parameter not in selected: - selected.append(parameter) - - return selected - - def selected_groups(self): - """ - Returns a list of selected parameter groups in the view. - - Returns: - list: A list of selected parameter group names. - """ - selected = set() - for index in self.selectedIndexes(): - group = self.model().get(index, "_group") - group and selected.add(group) - - return list(selected) - -class ProjectParametersModel(core.ABTreeModel): - """ - A model representing the data for all project parameters. - """ - - def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: - """ - Sets the data for the given index. - - Args: - index (QtCore.QModelIndex): The index to set data for. - value: The value to set. - role (int): The role for which to set the data. - - Returns: - bool: True if the data was set successfully, False otherwise. - """ - if role != Qt.ItemDataRole.EditRole: - return False - - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return False - - # Handle "New parameter..." rows - if row.get("_class") == "new": - if column_name != "name" or value == "": - return False - - parameter = Parameter( - name=value, - group=row.get("_group"), - param_type=row.get("_param_type") - ) - - app.actions.ParameterNewFromParameter.run(parameter) - return True - - # Handle regular parameter edits - parameter = row.get("_parameter") - if parameter is None: - return False - - if column_name in ["amount", "formula", "name", "comment"]: - parameter = refresh_parameter(parameter) - app.actions.ParameterModify.run(parameter, column_name, value) - - if column_name == "uncertainty": - parameter = refresh_parameter(parameter) - app.actions.ParameterUncertaintyModify.run(parameter.to_peewee_model(), uncertainty_dict=value) - - return True - - return False - - def decorationData(self, index: QtCore.QModelIndex) -> any: - """ - Provides decoration data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide decoration data. - - Returns: - The decoration data for the index. - """ - column_name = self.column_name(index) - - if column_name == "amount": - formula = self.get(index, "formula") - formula = isinstance(formula, str) and formula.strip() - - return icons.qicons.parameterized if formula else icons.qicons.empty - - return None - - def fontData(self, index: QtCore.QModelIndex) -> any: - """ - Provides font data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide font data. - - Returns: - QtGui.QFont: The font data for the index. - """ - param_class = self.get(index, "_class") - if param_class == "new": - font = QtGui.QFont() - font.setWeight(QtGui.QFont.Weight.ExtraLight) - return font - - if param_class == "broken": - font = QtGui.QFont() - font.setWeight(QtGui.QFont.Weight.Bold) - return font - - return None - - def indexEditable(self, index: QtCore.QModelIndex) -> bool: - """ - Returns whether the index is editable. - - Args: - index (QtCore.QModelIndex): The index to check. - - Returns: - bool: True if the index is editable, False otherwise. - """ - column_name = self.column_name(index) - - # Check if database is locked - database = self.get(index, "_database") - if not pd.isna(database) and database_is_locked(database): - return False - - # Prevent editing broken parameters - if self.get(index, "_class") == "broken": - return False - - # Allow editing for specific columns - if column_name in ["formula", "uncertainty", "name", "comment"]: - return True - - if column_name == "amount" and not self.get(index, "formula"): - return True - - return False - - def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: - """ - Returns the parameters in scope of the parameter at the given index. - - Args: - index (QtCore.QModelIndex): The index to get scoped parameters for. - - Returns: - dict: The parameters in scope. - """ - parameter = self.get(index, "_parameter") - if parameter is None or isinstance(parameter, float): # NaN check - return {} - - return parameters_in_scope(parameter=parameter) - diff --git a/activity_browser/app/pages/settings/README.md b/activity_browser/app/pages/settings/README.md deleted file mode 100644 index 830bf407f..000000000 --- a/activity_browser/app/pages/settings/README.md +++ /dev/null @@ -1,194 +0,0 @@ -# Settings Module - -This module contains the settings page and its chapters. - -## Structure - -``` -settings/ -├── __init__.py # Module exports -├── settings_page.py # Main SettingsPage class -├── base.py # BaseSettingsChapter (base class for all chapters) -├── startup.py # StartupSettingsChapter -├── appearance.py # AppearanceSettingsChapter -├── project_manager.py # ProjectManagerSettingsChapter -├── metadatastore.py # MetadataStoreSettingsChapter -├── plugins.py # PluginsSettingsChapter -└── README.md # This file -``` - -## Adding a New Chapter - -### Step 1: Create a new chapter file - -Create a new file in this directory, e.g., `my_chapter.py`: - -```python -# -*- coding: utf-8 -*- -from loguru import logger -from qtpy import QtWidgets - -from activity_browser.settings import ab_settings -from .base import BaseSettingsChapter - - -class MySettingsChapter(BaseSettingsChapter): - """Chapter for my settings.""" - - def __init__(self, parent=None): - super().__init__(parent) - - # Create your widgets - self.my_widget = QtWidgets.QLineEdit() - - self.build_layout() - self.connect_signals() - - def connect_signals(self): - """Connect signals for change tracking.""" - self.my_widget.textChanged.connect(self.changed.emit) - - def build_layout(self): - """Build the chapter layout.""" - layout = QtWidgets.QVBoxLayout() - - # Create your UI - group = QtWidgets.QGroupBox("My Settings") - group_layout = QtWidgets.QGridLayout() - group_layout.addWidget(QtWidgets.QLabel("Setting:"), 0, 0) - group_layout.addWidget(self.my_widget, 0, 1) - group.setLayout(group_layout) - - layout.addWidget(group) - layout.addStretch() - - self.setLayout(layout) - - def get_current_state(self): - """Return current state for change tracking.""" - return { - 'my_setting': self.my_widget.text(), - } - - def save_settings(self): - """Save chapter-specific settings.""" - ab_settings.my_setting = self.my_widget.text() - logger.info("Saved my settings") - - def reset(self): - """Reset chapter to initial values.""" - self.my_widget.setText(ab_settings.my_setting) - - def restore_defaults(self): - """Restore default values.""" - self.my_widget.setText("default value") -``` - -### Step 2: Import in settings_page.py - -In `settings_page.py`, add your import: - -```python -from .my_chapter import MySettingsChapter -``` - -### Step 3: Add to chapters list - -In the `SettingsPage.__init__()` method, add your chapter: - -```python -# Create chapters -self.startup_chapter = StartupSettingsChapter(self) -self.appearance_chapter = AppearanceSettingsChapter(self) -self.my_chapter = MySettingsChapter(self) # <-- Add this - -# Add chapters to the stack -self.chapters = [ - ("Startup", self.startup_chapter), - ("Appearance", self.appearance_chapter), - ("My Chapter", self.my_chapter), # <-- And this -] -``` - -That's it! Your new chapter is now integrated. - -## BaseSettingsChapter Interface - -All chapters must inherit from `BaseSettingsChapter` and implement these methods: - -- **`get_current_state()`** - Return the current state as a dictionary for change tracking -- **`save_settings()`** - Save the chapter's settings to `ab_settings` -- **`reset()`** - Reset widgets to current `ab_settings` values -- **`restore_defaults()`** - Set widgets to default values - -### Change Tracking - -The base class automatically tracks changes using the `changed` signal: - -1. Override `get_current_state()` to return a dictionary of current values -2. Connect widget signals to `self.changed.emit()` to notify of changes -3. The save button will be enabled/disabled automatically based on changes - -Example: -```python -def __init__(self, parent=None): - super().__init__(parent) - self.my_widget = QtWidgets.QLineEdit() - self.build_layout() - self.connect_signals() - -def connect_signals(self): - # Emit changed signal when the widget changes - self.my_widget.textChanged.connect(self.changed.emit) - -def get_current_state(self): - """Return current state for change tracking.""" - return { - 'my_value': self.my_widget.text(), - } -``` - -## Existing Chapters - -### StartupSettingsChapter (`startup.py`) -Manages: -- Brightway directory selection and management -- Startup project selection -- Directory validation and project discovery - -### AppearanceSettingsChapter (`appearance.py`) -Manages: -- Theme selection (Light/Dark) -- Future: Font sizes, colors, etc. - -### ProjectManagerSettingsChapter (`project_manager.py`) -Manages: -- Project management settings -- Project creation and deletion - -### MetadataStoreSettingsChapter (`metadatastore.py`) -Manages: -- Metadata store caching settings -- Searcher configuration - -### PluginsSettingsChapter (`plugins.py`) -Manages: -- List of enabled Python plugins -- Add/remove plugin packages that should be imported at startup - -## Testing - -Test the settings page with: - -```bash -python test_settings_page.py -``` - -## Best Practices - -1. **Keep chapters focused** - Each chapter should handle a specific area of settings -2. **Use QGroupBox** - Organize widgets within chapters using group boxes -3. **Add tooltips** - Help users understand what each setting does -4. **Validate input** - Check settings before saving -5. **Log changes** - Use logger to record setting changes -6. **Handle errors gracefully** - Show appropriate error messages to users diff --git a/activity_browser/app/pages/settings/__init__.py b/activity_browser/app/pages/settings/__init__.py deleted file mode 100644 index 521c047d9..000000000 --- a/activity_browser/app/pages/settings/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -from .settings_page import SettingsPage -from .base import BaseSettingsChapter -from .startup import StartupSettingsChapter -from .appearance import AppearanceSettingsChapter -from .project_manager import ProjectManagerSettingsChapter -from .metadatastore import MetadataStoreSettingsChapter -from .plugins import PluginsSettingsChapter - -__all__ = [ - "SettingsPage", - "BaseSettingsChapter", - "StartupSettingsChapter", - "AppearanceSettingsChapter", - "ProjectManagerSettingsChapter", - "MetadataStoreSettingsChapter", - "PluginsSettingsChapter", -] diff --git a/activity_browser/app/pages/settings/appearance.py b/activity_browser/app/pages/settings/appearance.py deleted file mode 100644 index 1ced98e4e..000000000 --- a/activity_browser/app/pages/settings/appearance.py +++ /dev/null @@ -1,98 +0,0 @@ -# -*- coding: utf-8 -*- -from loguru import logger -from qtpy import QtWidgets - -from activity_browser.app import settings -from activity_browser.app.pages.settings.base import BaseSettingsChapter - - -class AppearanceSettingsChapter(BaseSettingsChapter): - """Chapter for appearance-related settings.""" - - theme_map = { - "default": "System default", - "light": "Light theme", - "dark": "Dark theme", - } - - pane_tab_position_map = { - "top": "Top", - "bottom": "Bottom", - "left": "Left", - "right": "Right", - } - - def __init__(self, parent=None): - super().__init__(parent) - - # Theme selector - self.theme_combo = QtWidgets.QComboBox() - - # Pane tab position selector - self.pane_tab_position_combo = QtWidgets.QComboBox() - - self.build_layout() - self.connect_signals() - self.reset() - - def connect_signals(self): - """Connect signals and slots.""" - # Emit changed signal when settings change - self.theme_combo.currentTextChanged.connect(lambda: self.changed.emit()) - self.pane_tab_position_combo.currentTextChanged.connect(lambda: self.changed.emit()) - - def build_layout(self): - """Build the chapter layout.""" - layout = QtWidgets.QVBoxLayout() - - # Theme section - theme_group = QtWidgets.QGroupBox("Theme") - theme_layout = QtWidgets.QGridLayout() - theme_layout.addWidget(QtWidgets.QLabel("Theme:"), 0, 0) - theme_layout.addWidget(self.theme_combo, 0, 1) - theme_group.setLayout(theme_layout) - - # Pane tab position section - pane_tab_group = QtWidgets.QGroupBox("Pane Tab Position") - pane_tab_layout = QtWidgets.QGridLayout() - pane_tab_layout.addWidget(QtWidgets.QLabel("Position:"), 0, 0) - pane_tab_layout.addWidget(self.pane_tab_position_combo, 0, 1) - pane_tab_group.setLayout(pane_tab_layout) - - layout.addWidget(theme_group) - layout.addWidget(pane_tab_group) - layout.addStretch() - - self.setLayout(layout) - - # --- Settings management methods --- # - def reset(self): - """(Re)set to initial values.""" - self.theme_combo.clear() - self.theme_combo.addItems(self.theme_map.values()) - self.theme_combo.setCurrentText(self.theme_map.get(settings["appearance"]["theme"], "System default")) - - self.pane_tab_position_combo.clear() - self.pane_tab_position_combo.addItems(self.pane_tab_position_map.values()) - self.pane_tab_position_combo.setCurrentText(self.pane_tab_position_map.get(settings["appearance"]["pane_tab_position"], "Bottom")) - - def has_changes(self): - """Check if there are unsaved changes.""" - current_state = { - 'theme': self.theme_combo.currentText(), - 'pane_tab_position': self.pane_tab_position_combo.currentText(), - } - initial_state = { - 'theme': self.theme_map.get(settings["appearance"]["theme"], "System default"), - 'pane_tab_position': self.pane_tab_position_map.get(settings["appearance"]["pane_tab_position"], "Bottom"), - } - return current_state != initial_state - - def set_settings(self): - """Save appearance settings.""" - new_theme = self.theme_combo.currentText() - settings["appearance"]["theme"] = [key for key, value in self.theme_map.items() if value == new_theme][0] - - new_pane_position = self.pane_tab_position_combo.currentText() - settings["appearance"]["pane_tab_position"] = [key for key, value in self.pane_tab_position_map.items() if value == new_pane_position][0] - diff --git a/activity_browser/app/pages/settings/base.py b/activity_browser/app/pages/settings/base.py deleted file mode 100644 index 9cd455f02..000000000 --- a/activity_browser/app/pages/settings/base.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -from qtpy import QtCore, QtWidgets - - -class BaseSettingsChapter(QtWidgets.QWidget): - """Base class for settings chapters.""" - - # Signal emitted when settings change - changed = QtCore.Signal() - - def __init__(self, parent=None): - super().__init__(parent) - self.settings_page = parent - self._initial_state = None - - def get_current_state(self): - """ - Override this to return the current state of the chapter. - Should return a dictionary or tuple representing current values. - """ - return {} - - def has_changes(self): - """Check if the chapter has unsaved changes.""" - if self._initial_state is None: - return False - return self.get_current_state() != self._initial_state - - def save_settings(self): - """Override this to save chapter-specific settings.""" - pass - - def reset(self): - """Override this to reset chapter to initial values.""" - pass - - def restore_defaults(self): - """Override this to restore default values.""" - pass diff --git a/activity_browser/app/pages/settings/metadatastore.py b/activity_browser/app/pages/settings/metadatastore.py deleted file mode 100644 index 7659d4239..000000000 --- a/activity_browser/app/pages/settings/metadatastore.py +++ /dev/null @@ -1,125 +0,0 @@ -# -*- coding: utf-8 -*- -from loguru import logger -from qtpy import QtWidgets - -from activity_browser.app import settings -from activity_browser.app.actions.metadatastore_cache_clear import MetaDataStoreCacheClear -from activity_browser.app.pages.settings.base import BaseSettingsChapter - - -class MetadataStoreSettingsChapter(BaseSettingsChapter): - """Chapter for metadatastore-related settings.""" - - def __init__(self, parent=None): - super().__init__(parent) - - # Caching enabled checkbox - self.caching_checkbox = QtWidgets.QCheckBox("Enable caching") - self.caching_checkbox.setToolTip( - "Enable caching for faster data access. " - "Disable if you experience memory issues or want to force fresh data loading." - ) - - # Searcher enabled checkbox - self.searcher_checkbox = QtWidgets.QCheckBox("Enable searcher") - self.searcher_checkbox.setToolTip( - "Enable the full-text search functionality for activities and metadata. " - "Disable if you experience performance issues with large databases." - ) - - # Clear cache button - self.clear_cache_button = QtWidgets.QPushButton("Clear Cache") - self.clear_cache_button.setToolTip( - "Clear the metadata store cache and reload the current project. " - "Use this if you experience issues with outdated or corrupted cache data." - ) - - self.build_layout() - self.connect_signals() - self.reset() - - def connect_signals(self): - """Connect signals and slots.""" - # Emit changed signal when settings change - self.caching_checkbox.stateChanged.connect(lambda: self.changed.emit()) - self.searcher_checkbox.stateChanged.connect(lambda: self.changed.emit()) - - # Connect clear cache button - self.clear_cache_button.clicked.connect(MetaDataStoreCacheClear.run) - - def build_layout(self): - """Build the chapter layout.""" - layout = QtWidgets.QVBoxLayout() - - # Metadata store group - metadatastore_group = QtWidgets.QGroupBox("Metadata Store Options") - metadatastore_layout = QtWidgets.QVBoxLayout() - - metadatastore_layout.addWidget(self.caching_checkbox) - metadatastore_layout.addWidget(self.searcher_checkbox) - - # Add clear cache button - metadatastore_layout.addWidget(self.clear_cache_button) - - # Add description label - description = QtWidgets.QLabel( - "These settings control the behavior of the metadata store, " - "which manages activity data for improved performance." - ) - description.setWordWrap(True) - description.setStyleSheet("color: gray; font-size: 10pt;") - metadatastore_layout.addWidget(description) - - metadatastore_group.setLayout(metadatastore_layout) - - layout.addWidget(metadatastore_group) - layout.addStretch() - - self.setLayout(layout) - - # --- Settings management methods --- # - def reset(self): - """(Re)set to initial values.""" - try: - self.caching_checkbox.setChecked( - settings["metadatastore"]["caching_enabled"] - ) - self.searcher_checkbox.setChecked( - settings["metadatastore"]["searcher_enabled"] - ) - except (KeyError, TypeError): - # Use defaults if settings don't exist yet - self.caching_checkbox.setChecked(True) - self.searcher_checkbox.setChecked(True) - - def has_changes(self): - """Check if there are unsaved changes.""" - try: - current_state = { - 'caching_enabled': self.caching_checkbox.isChecked(), - 'searcher_enabled': self.searcher_checkbox.isChecked(), - } - initial_state = { - 'caching_enabled': settings["metadatastore"]["caching_enabled"], - 'searcher_enabled': settings["metadatastore"]["searcher_enabled"], - } - return current_state != initial_state - except (KeyError, TypeError): - # If settings don't exist, check against defaults - return (self.caching_checkbox.isChecked() != True or - self.searcher_checkbox.isChecked() != True) - - def set_settings(self): - """Save metadatastore settings.""" - if "metadatastore" not in settings.global_config: - settings.global_config["metadatastore"] = {} - - settings.global_config["metadatastore"]["caching_enabled"] = self.caching_checkbox.isChecked() - settings.global_config["metadatastore"]["searcher_enabled"] = self.searcher_checkbox.isChecked() - - logger.info( - f"Metadatastore settings saved: " - f"caching={self.caching_checkbox.isChecked()}, " - f"searcher={self.searcher_checkbox.isChecked()}" - ) - diff --git a/activity_browser/app/pages/settings/plugins.py b/activity_browser/app/pages/settings/plugins.py deleted file mode 100644 index bd743a624..000000000 --- a/activity_browser/app/pages/settings/plugins.py +++ /dev/null @@ -1,166 +0,0 @@ -# -*- coding: utf-8 -*- -import importlib.util -from loguru import logger -from qtpy import QtWidgets - -from activity_browser.app import settings -from activity_browser.app.pages.settings.base import BaseSettingsChapter -from activity_browser.ui.icons import qicons - - -class PluginsSettingsChapter(BaseSettingsChapter): - """Chapter for plugin-related settings.""" - - def __init__(self, parent=None): - super().__init__(parent) - - # List widget to display enabled plugins - self.plugin_list = QtWidgets.QListWidget() - self.plugin_list.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) - - # Input field for adding new plugins - self.plugin_input = QtWidgets.QLineEdit() - self.plugin_input.setPlaceholderText("Enter Python package name (e.g., my_plugin)") - - # Buttons - self.add_button = QtWidgets.QPushButton("Add") - self.remove_button = QtWidgets.QPushButton("Remove") - self.remove_button.setEnabled(False) - - self.build_layout() - self.connect_signals() - self.reset() - - def connect_signals(self): - """Connect signals and slots.""" - self.add_button.clicked.connect(self.add_plugin) - self.remove_button.clicked.connect(self.remove_plugin) - self.plugin_input.returnPressed.connect(self.add_plugin) - self.plugin_list.itemSelectionChanged.connect(self.on_selection_changed) - self.plugin_list.model().rowsInserted.connect(lambda: self.changed.emit()) - self.plugin_list.model().rowsRemoved.connect(lambda: self.changed.emit()) - - def build_layout(self): - """Build the chapter layout.""" - layout = QtWidgets.QVBoxLayout() - - # Plugin list section - plugin_group = QtWidgets.QGroupBox("Enabled Plugins") - plugin_layout = QtWidgets.QVBoxLayout() - - # Description label - description = QtWidgets.QLabel( - "Add Python packages that should be imported as plugins.\n" - "These packages will be loaded when Activity Browser starts." - ) - description.setWordWrap(True) - plugin_layout.addWidget(description) - - # List widget - plugin_layout.addWidget(self.plugin_list) - - # Input section - input_layout = QtWidgets.QHBoxLayout() - input_layout.addWidget(self.plugin_input) - input_layout.addWidget(self.add_button) - plugin_layout.addLayout(input_layout) - - # Remove button - plugin_layout.addWidget(self.remove_button) - - plugin_group.setLayout(plugin_layout) - - layout.addWidget(plugin_group) - layout.addStretch() - - self.setLayout(layout) - - def on_selection_changed(self): - """Enable/disable remove button based on selection.""" - self.remove_button.setEnabled(len(self.plugin_list.selectedItems()) > 0) - - def module_exists(self, module_name): - """Check if a module can be found/imported.""" - try: - spec = importlib.util.find_spec(module_name) - return spec is not None - except (ImportError, ModuleNotFoundError, ValueError, AttributeError): - return False - - def add_plugin_to_list(self, plugin_name): - """Add a plugin to the list widget with appropriate icon.""" - item = QtWidgets.QListWidgetItem(plugin_name) - - # Check if module exists and add warning icon if not - if not self.module_exists(plugin_name): - # Use standard warning icon - icon = qicons.critical - item.setIcon(icon) - item.setToolTip(f"Warning: Module '{plugin_name}' not found. " - "Make sure it is installed before starting Activity Browser.") - logger.warning(f"Plugin module '{plugin_name}' not found") - else: - icon = qicons.empty - item.setIcon(icon) - item.setToolTip(f"Module '{plugin_name}' is available") - - self.plugin_list.addItem(item) - - def add_plugin(self): - """Add a plugin to the list.""" - plugin_name = self.plugin_input.text().strip() - if not plugin_name: - return - - # Check if plugin already exists - existing_items = [self.plugin_list.item(i).text() for i in range(self.plugin_list.count())] - if plugin_name in existing_items: - QtWidgets.QMessageBox.warning( - self, - "Duplicate Plugin", - f"The plugin '{plugin_name}' is already in the list." - ) - return - - # Add to list with icon - self.add_plugin_to_list(plugin_name) - self.plugin_input.clear() - logger.debug(f"Added plugin: {plugin_name}") - self.changed.emit() - - def remove_plugin(self): - """Remove selected plugin from the list.""" - selected_items = self.plugin_list.selectedItems() - if not selected_items: - return - - for item in selected_items: - plugin_name = item.text() - row = self.plugin_list.row(item) - self.plugin_list.takeItem(row) - logger.debug(f"Removed plugin: {plugin_name}") - - self.changed.emit() - - # --- Settings management methods --- # - def reset(self): - """(Re)set to initial values.""" - self.plugin_list.clear() - enabled_plugins = settings["plugins"].get("enabled_plugins", []) - for plugin in enabled_plugins: - self.add_plugin_to_list(plugin) - self.plugin_input.clear() - self.remove_button.setEnabled(False) - - def has_changes(self): - """Check if there are unsaved changes.""" - current_plugins = [self.plugin_list.item(i).text() for i in range(self.plugin_list.count())] - saved_plugins = settings["plugins"].get("enabled_plugins", []) - return current_plugins != saved_plugins - - def set_settings(self): - """Save plugin settings.""" - current_plugins = [self.plugin_list.item(i).text() for i in range(self.plugin_list.count())] - settings["plugins"]["enabled_plugins"] = current_plugins - logger.info(f"Saved enabled plugins: {current_plugins}") - diff --git a/activity_browser/app/pages/settings/project_manager.py b/activity_browser/app/pages/settings/project_manager.py deleted file mode 100644 index 925550825..000000000 --- a/activity_browser/app/pages/settings/project_manager.py +++ /dev/null @@ -1,227 +0,0 @@ -from loguru import logger - -import pandas as pd -from qtpy import QtWidgets, QtGui - -import bw2data as bd -from bw2io import remote - -from activity_browser import app, ui -from activity_browser.bwutils.commontasks import get_templates -from activity_browser.ui import widgets, core - -from .base import BaseSettingsChapter - - -class ProjectManagerSettingsChapter(BaseSettingsChapter): - """Chapter for project and template management.""" - - def __init__(self, parent=None): - super().__init__(parent) - - self.tabs = QtWidgets.QTabWidget(self) - - self.project_model = ProjectModel(parent=self, enable_sorting=True) - self.template_model = TemplateModel(parent=self, enable_sorting=True) - - self.project_view = ProjectView(self) - self.project_view.setModel(self.project_model) - - self.template_view = TemplateView(self) - self.template_view.setModel(self.template_model) - - self.tabs.addTab(self.project_view, "Projects") - self.tabs.addTab(self.template_view, "Templates") - - self.build_layout() - self.connect_signals() - - def build_layout(self): - """Build the chapter layout.""" - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.tabs) - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - - def connect_signals(self): - """Connect signals and slots.""" - app.signals.project.deleted.connect(self.sync) - - def sync(self): - """Sync project and template data.""" - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - df = self.build_project_df() - self.project_model.set_dataframe(df) - self.project_view.resizeColumnToContents(1) - - df = self.build_template_df() - self.template_model.set_dataframe(df) - self.template_view.resizeColumnToContents(1) - - def reset(self): - """Reset to initial values.""" - self.sync() - - def has_changes(self): - """Project manager doesn't have editable settings.""" - return False - - def set_settings(self): - """No settings to save for project manager.""" - pass - - def build_project_df(self) -> pd.DataFrame: - """Build DataFrame for projects.""" - data = [] - for proj_ds in sorted(bd.projects): - # if for any reason the project data is not a dictionary, log a warning and set it to an empty dict - if not isinstance(proj_ds.data, dict): - logger.warning(f"Project {proj_ds.name} has no data dictionary") - proj_ds.data = {} - - data.append({ - "name": proj_ds.name, - "path": proj_ds.dir, - "version": "Brightway25" if proj_ds.data.get("25", False) else "Legacy" - }) - - cols = ["name", "version", "path"] - return pd.DataFrame(data, columns=cols) - - def build_template_df(self) -> pd.DataFrame: - """Build DataFrame for templates.""" - data = [] - - templates = get_templates() - remote_templates = remote.get_projects() - - for name in sorted(templates): - data.append({ - "name": name, - "path": templates[name], - "remote": "No" - }) - - for name in sorted(remote_templates): - data.append({ - "name": name, - "path": remote_templates[name], - "remote": "Yes" - }) - - cols = ["name", "path", "remote"] - return pd.DataFrame(data, columns=cols) - - -class ProjectView(widgets.ABTreeView): - - class ContextMenu(widgets.ABMenu): - menuSetup = [ - lambda m, p: m.addMenu(p.get_project_new_menu(m)), - lambda m, p: m.addSeparator() if p.has_selection else None, - lambda m, p: m.add(app.actions.ProjectDuplicate, p.selected_project, - enable=p.single_selection) if p.single_selection else None, - lambda m, p: m.add(app.actions.ProjectCreateTemplate, p.selected_project, m.parent(), - enable=p.single_selection) if p.single_selection else None, - lambda m, p: m.add(app.actions.ProjectMigrate25, p.selected_project, - enable=(p.single_selection and p.is_legacy)) if p.single_selection and p.is_legacy else None, - lambda m, p: m.addSeparator() if p.has_selection else None, - lambda m, p: m.add(app.actions.ProjectDelete, p.selected_projects, - enable=p.has_selection) if p.has_selection else None, - ] - - def __init__(self, parent): - super().__init__(parent) - self.setSortingEnabled(True) - self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) - self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) - - def get_project_new_menu(self, parent): - """Get the ProjectNewMenu.""" - from activity_browser.app.menu_bar import ProjectNewMenu - return ProjectNewMenu(parent) - - @property - def selected_projects(self) -> list: - if not self.selectedIndexes(): - return [] - names = self.model().values_from_indices("name", self.selectedIndexes()) - return list(set(names)) - - @property - def selected_project(self): - return self.selected_projects[0] if self.single_selection else None - - @property - def single_selection(self): - return len(self.selected_projects) == 1 - - @property - def has_selection(self): - return len(self.selected_projects) > 0 - - @property - def is_legacy(self): - if not self.single_selection: - return False - index = self.selectedIndexes()[0] - return self.model().get(index, "version") == "Legacy" - - -class ProjectModel(core.ABTreeModel): - """Model for project data.""" - - def fontData(self, index): - """Provide font data for the model.""" - column_name = self.column_name(index) - - if column_name == "name": - font = QtGui.QFont() - font.setWeight(QtGui.QFont.Weight.DemiBold) - return font - - return None - - def decorationData(self, index): - """Provide icon decoration for the model.""" - column_name = self.column_name(index) - - if column_name == "name": - name = self.get(index, "name") - if name == app.settings["startup"]["startup_project"]: - return ui.icons.qicons.star - if name == bd.projects.current: - return ui.icons.qicons.forward - - return ui.icons.qicons.empty - - return None - - -class TemplateView(widgets.ABTreeView): - - class ContextMenu(widgets.ABMenu): - menuSetup = [] - - def __init__(self, parent): - super().__init__(parent) - self.setSortingEnabled(True) - self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) - - -class TemplateModel(core.ABTreeModel): - """Model for template data.""" - - def fontData(self, index): - """Provide font data for the model.""" - column_name = self.column_name(index) - - if column_name == "name": - font = QtGui.QFont() - font.setWeight(QtGui.QFont.Weight.DemiBold) - return font - - return None - - diff --git a/activity_browser/app/pages/settings/settings_page.py b/activity_browser/app/pages/settings/settings_page.py deleted file mode 100644 index ffbe139c3..000000000 --- a/activity_browser/app/pages/settings/settings_page.py +++ /dev/null @@ -1,159 +0,0 @@ -# -*- coding: utf-8 -*- -from loguru import logger -from pathlib import Path - -from qtpy import QtWidgets - -from bw2data import projects - -from activity_browser.app import settings, signals - -from .startup import StartupSettingsChapter -from .appearance import AppearanceSettingsChapter -from .project_manager import ProjectManagerSettingsChapter -from .metadatastore import MetadataStoreSettingsChapter -from .plugins import PluginsSettingsChapter - - -class SettingsPage(QtWidgets.QWidget): - """Settings page with a sidebar navigation for different settings chapters.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setObjectName("SettingsPage") - - # Store initial state for cancel functionality - self.last_project = projects.current - self.last_bwdir = projects._base_data_dir - - # Chapter list (sidebar) - self.chapter_list = QtWidgets.QListWidget() - self.chapter_list.setMaximumWidth(200) - self.chapter_list.setMinimumWidth(100) - self.chapter_list.setSpacing(2) - - # Stacked widget for chapter content - self.content_stack = QtWidgets.QStackedWidget() - - # Create chapters - self.startup_chapter = StartupSettingsChapter(self) - self.appearance_chapter = AppearanceSettingsChapter(self) - self.project_manager_chapter = ProjectManagerSettingsChapter(self) - self.metadatastore_chapter = MetadataStoreSettingsChapter(self) - self.plugins_chapter = PluginsSettingsChapter(self) - - # Add chapters to the stack - self.chapters = [ - ("Startup", self.startup_chapter), - ("Appearance", self.appearance_chapter), - ("Projects", self.project_manager_chapter), - ("Metadata Store", self.metadatastore_chapter), - ("Plugins", self.plugins_chapter), - ] - - for name, widget in self.chapters: - self.chapter_list.addItem(name) - self.content_stack.addWidget(widget) - - # Select first chapter by default - self.chapter_list.setCurrentRow(0) - - # Buttons - self.button_layout = QtWidgets.QHBoxLayout() - self.save_button = QtWidgets.QPushButton("Save") - self.cancel_button = QtWidgets.QPushButton("Cancel") - self.restore_defaults_button = QtWidgets.QPushButton("Restore Defaults") - - self.button_layout.addWidget(self.restore_defaults_button) - self.button_layout.addStretch() - self.button_layout.addWidget(self.cancel_button) - self.button_layout.addWidget(self.save_button) - - # Build layout - self.build_layout() - self.connect_signals() - - # Store initial state and disable save button initially - self.save_button.setEnabled(False) - - def build_layout(self): - """Build the main layout with sidebar and content area.""" - # Main content area with sidebar and content - content_layout = QtWidgets.QHBoxLayout() - content_layout.addWidget(self.chapter_list) - - # Add vertical separator - separator = QtWidgets.QFrame() - separator.setFrameShape(QtWidgets.QFrame.VLine) - separator.setFrameShadow(QtWidgets.QFrame.Sunken) - content_layout.addWidget(separator) - - content_layout.addWidget(self.content_stack, 1) - - # Main layout - main_layout = QtWidgets.QVBoxLayout() - main_layout.setContentsMargins(5, 5, 5, 5) - main_layout.addLayout(content_layout, 1) - main_layout.addLayout(self.button_layout) - - self.setLayout(main_layout) - - # Set minimum size for resizability - self.setMinimumSize(400, 300) - - def connect_signals(self): - """Connect signals and slots.""" - signals.project.changed.connect(self.reset_all) - - self.chapter_list.currentRowChanged.connect(self.content_stack.setCurrentIndex) - self.save_button.clicked.connect(self.save_settings) - self.cancel_button.clicked.connect(self.cancel_settings) - self.restore_defaults_button.clicked.connect(self.restore_defaults) - - # Connect change signals from each chapter - for name, chapter in self.chapters: - if hasattr(chapter, 'changed'): - chapter.changed.connect(self.on_chapter_changed) - - def on_chapter_changed(self): - """Called when any chapter's settings change.""" - has_changes = self.has_changes() - self.save_button.setEnabled(has_changes) - - def has_changes(self): - """Check if any chapter has unsaved changes.""" - for name, chapter in self.chapters: - if hasattr(chapter, 'has_changes') and chapter.has_changes(): - return True - return False - - def save_settings(self): - """Save all settings from all chapters.""" - for name, chapter in self.chapters: - if hasattr(chapter, 'set_settings'): - chapter.set_settings() - - settings.save() - logger.info("Settings saved successfully") - - # Reset all chapters to the new saved state - self.reset_all() - - def cancel_settings(self): - """Cancel changes and revert to previous state.""" - logger.info("Cancelling settings changes") - self.reset_all() - - def restore_defaults(self): - """Restore default settings for the current chapter.""" - logger.info("Restoring default settings") - settings.restore_defaults() - self.reset_all() - - def reset_all(self): - """Reset all chapters to their initial states.""" - for name, chapter in self.chapters: - if hasattr(chapter, 'reset'): - chapter.reset() - self.save_button.setEnabled(False) - diff --git a/activity_browser/app/pages/settings/startup.py b/activity_browser/app/pages/settings/startup.py deleted file mode 100644 index aa5b3c7f6..000000000 --- a/activity_browser/app/pages/settings/startup.py +++ /dev/null @@ -1,218 +0,0 @@ -# -*- coding: utf-8 -*- -import os -from loguru import logger -from pathlib import Path - -from peewee import SqliteDatabase, OperationalError -from qtpy import QtCore, QtWidgets - -from bw2data import projects - -from activity_browser.app import settings, panes, pages -from .base import BaseSettingsChapter - - -class StartupSettingsChapter(BaseSettingsChapter): - """Chapter for startup-related settings.""" - - def __init__(self, parent=None): - super().__init__(parent) - - # Brightway directory - self.bwdir_combo = QtWidgets.QComboBox() - self.bwdir_browse_button = QtWidgets.QPushButton("Browse") - self.bwdir_remove_button = QtWidgets.QPushButton("Remove") - - # Startup project - self.startup_project_combo = QtWidgets.QComboBox() - - # Shown panes checkboxes - self.pane_checkboxes = {} - self.available_panes = list(panes.base_panes.keys()) - for pane_name in self.available_panes: - self.pane_checkboxes[pane_name] = QtWidgets.QCheckBox(pane_name) - - # Shown pages checkboxes - self.page_checkboxes = {} - self.available_pages = list(pages.base_pages.keys()) - for page_name in self.available_pages: - self.page_checkboxes[page_name] = QtWidgets.QCheckBox(page_name) - - self.build_layout() - self.connect_signals() - self.reset() - - def build_layout(self): - """Build the chapter layout.""" - layout = QtWidgets.QVBoxLayout() - - # Brightway directory section - bwdir_group = QtWidgets.QGroupBox("Brightway Directory") - bwdir_layout = QtWidgets.QGridLayout() - bwdir_layout.addWidget(QtWidgets.QLabel("Directory:"), 0, 0) - bwdir_layout.addWidget(self.bwdir_combo, 0, 1) - bwdir_layout.addWidget(self.bwdir_browse_button, 0, 2) - bwdir_layout.addWidget(self.bwdir_remove_button, 0, 3) - bwdir_group.setLayout(bwdir_layout) - - # Startup project section - project_group = QtWidgets.QGroupBox("Startup Project") - project_layout = QtWidgets.QGridLayout() - project_layout.addWidget(QtWidgets.QLabel("Project:"), 0, 0) - project_layout.addWidget(self.startup_project_combo, 0, 1) - project_group.setLayout(project_layout) - - # Shown panes section - panes_group = QtWidgets.QGroupBox("Panes shown at startup") - panes_layout = QtWidgets.QVBoxLayout() - for pane_name in self.available_panes: - panes_layout.addWidget(self.pane_checkboxes[pane_name]) - panes_group.setLayout(panes_layout) - - # Shown pages section - pages_group = QtWidgets.QGroupBox("Pages shown at startup") - pages_layout = QtWidgets.QVBoxLayout() - for page_name in self.available_pages: - pages_layout.addWidget(self.page_checkboxes[page_name]) - pages_group.setLayout(pages_layout) - - layout.addWidget(bwdir_group) - layout.addWidget(project_group) - layout.addWidget(panes_group) - layout.addWidget(pages_group) - layout.addStretch() - - self.setLayout(layout) - - def connect_signals(self): - """Connect signals and slots.""" - self.bwdir_browse_button.clicked.connect(self.browse_bwdir) - self.bwdir_remove_button.clicked.connect(self.remove_bwdir) - - # Emit changed signal when settings change - self.bwdir_combo.currentTextChanged.connect(lambda: self.changed.emit()) - self.bwdir_combo.currentTextChanged.connect(self.show_virtual_projects) - self.startup_project_combo.currentTextChanged.connect(lambda: self.changed.emit()) - - # Connect checkboxes - for checkbox in self.pane_checkboxes.values(): - checkbox.stateChanged.connect(lambda: self.changed.emit()) - for checkbox in self.page_checkboxes.values(): - checkbox.stateChanged.connect(lambda: self.changed.emit()) - - # --- Settings management methods --- # - def reset(self): - """(Re)set to initial values.""" - self.bwdir_combo.clear() - self.bwdir_combo.addItems(settings["startup"].get("saved_brightway_directories", [])) - self.bwdir_combo.setCurrentText(settings["startup"]["brightway_directory"]) - - self.startup_project_combo.clear() - self.startup_project_combo.addItems(self.get_projects_from_path(settings["startup"]["brightway_directory"])) - self.startup_project_combo.setCurrentText(settings["startup"]["startup_project"]) - - # Set pane checkboxes - shown_panes = settings["startup"].get("shown_panes", []) - for pane_name, checkbox in self.pane_checkboxes.items(): - checkbox.setChecked(pane_name in shown_panes) - - # Set page checkboxes - shown_pages = settings["startup"].get("shown_pages", []) - for page_name, checkbox in self.page_checkboxes.items(): - checkbox.setChecked(page_name in shown_pages) - - def has_changes(self): - """Check if there are unsaved changes.""" - current_state = { - 'brightway_directory': self.bwdir_combo.currentText(), - 'saved_brightway_directories': [self.bwdir_combo.itemText(i) for i in range(self.bwdir_combo.count())], - 'startup_project': self.startup_project_combo.currentText(), - 'shown_panes': [name for name, cb in self.pane_checkboxes.items() if cb.isChecked()], - 'shown_pages': [name for name, cb in self.page_checkboxes.items() if cb.isChecked()], - } - initial_state = { - 'brightway_directory': settings["startup"]["brightway_directory"], - 'saved_brightway_directories': settings["startup"].get("saved_brightway_directories", []), - 'startup_project': settings["startup"]["startup_project"], - 'shown_panes': settings["startup"].get("shown_panes", []), - 'shown_pages': settings["startup"].get("shown_pages", []), - } - return current_state != initial_state - - def set_settings(self): - """Save startup settings.""" - - settings["startup"]["brightway_directory"] = self.bwdir_combo.currentText() - settings["startup"]["saved_brightway_directories"] = [self.bwdir_combo.itemText(i) for i in range(self.bwdir_combo.count())] - settings["startup"]["startup_project"] = self.startup_project_combo.currentText() - - # Save shown panes and pages - settings["startup"]["shown_panes"] = [name for name, cb in self.pane_checkboxes.items() if cb.isChecked()] - settings["startup"]["shown_pages"] = [name for name, cb in self.page_checkboxes.items() if cb.isChecked()] - - # --- Helper methods --- # - def browse_bwdir(self): - """Browse for a brightway directory.""" - path = Path(QtWidgets.QFileDialog.getExistingDirectory( - self, "Select a brightway2 database folder" - )) - if not path: - return - - if (path / "projects.db").is_file(): - self.bwdir_combo.addItem(str(path)) - self.bwdir_combo.setCurrentText(str(path)) - return - - reply = QtWidgets.QMessageBox.question( - self, - "New brightway data directory?", - 'This directory does not contain any projects. Switching to this directory will create a new brightway2 data folder here.', - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel, - ) - - if reply == QtWidgets.QMessageBox.Cancel: - return - - self.bwdir_combo.addItem(str(path)) - self.bwdir_combo.setCurrentText(str(path)) - - def remove_bwdir(self): - """Remove the selected brightway directory from the list.""" - reply = QtWidgets.QMessageBox.question( - self, - "Delete Brightway2 directory?", - "This action will remove the local information only, click 'Yes' to remove\n" - "the projects. Data on the 'disk' will remain untouched and needs to be removed manually", - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel, - ) - if reply == QtWidgets.QMessageBox.Cancel: - return - - removed_index = self.bwdir_combo.currentIndex() - self.bwdir_combo.setCurrentText(settings["startup"]["brightway_directory"]) - self.bwdir_combo.removeItem(removed_index) - - def show_virtual_projects(self): - """Show projects from the virtual Brightway directory.""" - virtual_projects = self.get_projects_from_path(self.bwdir_combo.currentText()) - startup = settings["startup"]["startup_project"] - - self.startup_project_combo.clear() - self.startup_project_combo.addItems(virtual_projects if virtual_projects else ["default"]) - self.startup_project_combo.setCurrentText(startup if startup in virtual_projects else "default") - - def get_projects_from_path(self, path: str): - """Get project names from a brightway directory.""" - database_file = os.path.join(path, "projects.db") - if not os.path.exists(database_file): - return [] - db = SqliteDatabase(database_file) - - try: - cursor = db.execute_sql('SELECT "name" FROM "projectdataset"') - except OperationalError as e: - if "no such table" in str(e): - return [] - raise - return [i[0] for i in cursor.fetchall()] diff --git a/activity_browser/app/pages/welcome.py b/activity_browser/app/pages/welcome.py deleted file mode 100644 index 25ef4fb65..000000000 --- a/activity_browser/app/pages/welcome.py +++ /dev/null @@ -1,75 +0,0 @@ -import os - -from qtpy import QtWebEngineWidgets, QtWidgets, QtCore, QtGui, QtWebChannel - -from activity_browser import app, app -from activity_browser.static import startscreen -from activity_browser.bwutils.commontasks import projects_by_last_opened - - -class WelcomePage(QtWidgets.QWidget): - html_file = os.path.join(startscreen.__path__[0], "welcome.html") - - def __init__(self, parent=None): - super().__init__(parent) - self.view = QtWebEngineWidgets.QWebEngineView() - self.page = WelcomeWebPage() - self.channel = QtWebChannel.QWebChannel(self) - self.bridge = Bridge(self) - self.channel.registerObject("bridge", self.bridge) - - self.url = QtCore.QUrl.fromLocalFile(self.html_file) - self.page.setWebChannel(self.channel) - self.page.load(self.url) - - # associate page with view - self.view.setPage(self.page) - - # set layout - self.vl = QtWidgets.QVBoxLayout() - self.vl.addWidget(self.view) - self.setLayout(self.vl) - - self.bridge.ready.connect(self.update_welcome) - app.signals.project.changed.connect(lambda: self.page.load(self.url)) - - def update_welcome(self): - projects = projects_by_last_opened() - projects = projects[1:5] if len(projects) > 5 else projects[1:] - project_names = [p.name for p in projects] - self.bridge.update.emit(project_names) - - -class Bridge(QtCore.QObject): - """ - A bridge for communication between Python and JavaScript. - - Attributes: - update_graph (SignalInstance): A signal to update the graph. - ready (SignalInstance): A signal indicating that the bridge is ready. - """ - update: QtCore.SignalInstance = QtCore.Signal(list) - ready: QtCore.SignalInstance = QtCore.Signal() - - @QtCore.Slot() - def is_ready(self): - """ - Emits the ready signal. - """ - self.ready.emit() - - @QtCore.Slot(str) - def open_project(self, project_name): - """ - Emits the ready signal. - """ - app.actions.ProjectSwitch.run(project_name) - -class WelcomeWebPage(QtWebEngineWidgets.QWebEnginePage): - def acceptNavigationRequest(self, qurl, navtype, mainframe): - # print("Navigation Request intercepted:", qurl) - if qurl.isLocalFile(): # open in Activity Browser QWebEngineView - return True - else: # delegate link to default browser - QtGui.QDesktopServices.openUrl(qurl) - return False diff --git a/activity_browser/app/panes/README.md b/activity_browser/app/panes/README.md deleted file mode 100644 index 6b6eb305c..000000000 --- a/activity_browser/app/panes/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# panes - -Dock-able side panels that can be arranged around the main content area. - -## Overview - -This directory contains pane widgets that can be docked to the edges of the main window or floated as separate windows. Panes provide quick access to navigation, information, and tools while working with the main content pages. - -## Purpose - -Panes offer: -- **Quick navigation** - Browse databases, activities, methods -- **Contextual information** - Show details about selected items -- **Tool access** - Quick access to common tools and operations -- **Workspace customization** - Users can arrange panes to suit their workflow - -## Pane Architecture - -Panes inherit from `AbstractPane` (in `ui/widgets/abstract_pane.py`) which provides: -- Dock widget functionality -- Consistent styling -- Signal connections -- State persistence (dock position, visibility) - -## Existing Panes -- **Databases Pane** - View of available databases -- **Database Products Pane** - Search and browse product-type nodes within a database -- **Impact Categories Pane** - Browse impact assessment methods -- **Calculation Setups Pane** - List of Calculation Setups - -## Pane Features - -### Docking Behavior -Panes can be: -- Docked to window edges (left, right, top, bottom) -- Stacked with other panes (tabbed) -- Floated as separate windows -- Resized by dragging dividers -- Hidden/shown via View menu - -### State Persistence -Pane positions and visibility are saved between sessions: -- Dock area and position -- Floating window geometry -- Visibility state -- Tab order when stacked - -## Usage Pattern - -```python -from activity_browser.ui.widgets import AbstractPane - -class MyPane(AbstractPane): - def __init__(self, parent=None): - super().__init__(parent) -``` - -## Development Guidelines - -When creating new panes: - -- **Inherit from AbstractPane** - Use the base class for consistency -- **Set pane title** - Use the standard `PaneNamePane` naming convention to set the title automatically -- **Base panes** - Add base panes to `__init__.py` in this directory so they are loaded by the main window on project change. - - -## Visibility Control - -Panes can be shown/hidden via: -- View menu (one menu item per pane) -- Toolbar buttons -- Keyboard shortcuts -- Context menu on title bar - -The main window tracks pane visibility and provides a centralized way to manage them. diff --git a/activity_browser/app/panes/__init__.py b/activity_browser/app/panes/__init__.py deleted file mode 100644 index e6b2aab5a..000000000 --- a/activity_browser/app/panes/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from .database_products import DatabaseProductsPane -from .databases import DatabasesPane -from .impact_categories import ImpactCategoriesPane -from .calculation_setups import CalculationSetupsPane - -base_panes = { - "Databases": DatabasesPane, - "Impact Categories": ImpactCategoriesPane, - "Calculation Setups": CalculationSetupsPane, -} diff --git a/activity_browser/app/panes/calculation_setups.py b/activity_browser/app/panes/calculation_setups.py deleted file mode 100644 index c5efdf014..000000000 --- a/activity_browser/app/panes/calculation_setups.py +++ /dev/null @@ -1,202 +0,0 @@ -from qtpy import QtWidgets, QtGui -from loguru import logger - -import bw2data as bd -import pandas as pd - -from activity_browser import app -from activity_browser.ui import widgets, delegates, core - - -class CalculationSetupsPane(widgets.ABAbstractPane): - title = "Calculation Setups" - unique = True - - def __init__(self, parent): - """ - Initializes the CalculationSetupsPane. - - This constructor sets up the view and model for displaying calculation setups, - configures the view's appearance and behavior, and builds the layout while - connecting necessary signals. - - Args: - parent (QtWidgets.QWidget): The parent widget for this pane. - """ - super().__init__(parent) - self.model = CalculationSetupsModel(parent=self) - self.view = CalculationSetupsView() - self.view.setModel(self.model) - - self.view.setAlternatingRowColors(True) - - self.build_layout() - self.connect_signals() - - def connect_signals(self): - """ - Connects the signals to the appropriate slots. - """ - app.signals.meta.calculation_setups_changed.connect(self.sync) - - def build_layout(self): - """ - Builds the layout of the widget. - """ - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.view) - layout.setContentsMargins(5, 0, 5, 5) - self.setLayout(layout) - - def sync(self): - """ - Synchronizes the model with the current state of the calculation setups. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - df = self.build_df() - self.model.set_dataframe(df) - self.view.resizeColumnToContents(0) - - def build_df(self) -> pd.DataFrame: - """ - Builds a DataFrame from the calculation setups. - - Returns: - pd.DataFrame: The DataFrame containing the calculation setups data. - """ - data = [] - for cs in bd.calculation_setups: - data.append( - { - "name": cs, - "functional_units": len(bd.calculation_setups[cs].get("inv", [])), - "impact_categories": len(bd.calculation_setups[cs].get("ia", [])), - } - ) - - cols = ["name", "functional_units", "impact_categories"] - - return pd.DataFrame(data, columns=cols) - - -class CalculationSetupsView(widgets.ABTreeView): - """ - A view that displays the calculation setups in a tree structure. - - Attributes: - defaultColumnDelegates (dict): The default column delegates for the view. - """ - defaultColumnDelegates = { - "name": delegates.StringDelegate, - } - - class ContextMenu(widgets.ABMenu): - menuSetup = [ - lambda m, p: m.add(app.actions.CSNew), - lambda m, p: m.add(app.actions.CSOpen, p.calculation_setups, - enable=bool(p.calculation_setups)), - lambda m, p: m.add(app.actions.CSDelete, p.calculation_setups, - enable=bool(p.calculation_setups)), - lambda m, p: m.add(app.actions.CSRename, p.calculation_setups[0] if p.single_selection else None, - enable=p.single_selection), - lambda m: m.addSeparator(), - lambda m, p: m.add(app.actions.CSCalculate, p.calculation_setups[0] if p.single_selection else None, - enable=p.single_selection), - ] - - @property - def calculation_setups(self): - if not self.selectedIndexes(): - return [] - names = self.model().values_from_indices("name", self.selectedIndexes()) - return list(set(names)) - - @property - def single_selection(self): - return len(self.calculation_setups) == 1 - - class HeaderMenu(QtWidgets.QMenu): - """ - A header menu for the DatabasesView. Currently not used. - """ - - def __init__(self, *args, **kwargs): - super().__init__() - - def __init__(self, parent=None): - super().__init__(parent) - self.setAcceptDrops(True) - - def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent): - """ - Handles the mouse double click event to open the selected calculation setups. - - Args: - event (QtGui.QMouseEvent): The mouse double click event. - """ - index = self.indexAt(event.pos()) - - if not index.isValid(): - return - - row = self.model().row(index) - if row is None: - return - - app.actions.CSOpen.run(row["name"]) - - - def dragMoveEvent(self, event) -> None: - pass - - def dragEnterEvent(self, event): - if event.mimeData().hasFormat("application/bw-nodekeylist"): - keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - for key in keys: - act = bd.get_node(key=key) - if act["type"] not in bd.labels.product_node_types + ["processwithreferenceproduct"]: - keys.remove(key) - - if not keys: - return - - event.accept() - - def dropEvent(self, event) -> None: - event.accept() - - keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - for key in keys: - act = bd.get_node(key=key) - if act["type"] not in bd.labels.product_node_types + ["processwithreferenceproduct"]: - keys.remove(key) - - functional_units = [{key: 1.0} for key in keys] - - app.actions.CSNew.run(functional_units=functional_units) - - -class CalculationSetupsModel(core.ABTreeModel): - """ - A model representing the data for the calculation setups. - """ - - def fontData(self, index): - """ - Provides font data for the model. - - Args: - index: The index for which to provide font data. - - Returns: - QtGui.QFont: The font data for the index. - """ - column_name = self.column_name(index) - - if column_name == "name": - font = QtGui.QFont() - font.setWeight(QtGui.QFont.Weight.DemiBold) - return font - - return None - diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py deleted file mode 100644 index e8a2fe6c1..000000000 --- a/activity_browser/app/panes/database_products.py +++ /dev/null @@ -1,608 +0,0 @@ -import threading - -from loguru import logger -from time import time -from threading import Thread - -import pandas as pd -from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt, QModelIndex - -import bw2data as bd - -from activity_browser import ui, app -from activity_browser.ui import core, widgets, delegates, icons -from activity_browser.bwutils.commontasks import database_is_locked, database_is_legacy, is_node_biosphere, nodes_to_excel - - -NODETYPES = { - "all_nodes": [], - "processes": ["process", "multifunctional", "processwithreferenceproduct", "nonfunctional"], - "products": ["product", "processwithreferenceproduct", "waste"], - "biosphere": ["natural resource", "emission", "inventory indicator", "economic", "social"], -} - - -class DatabaseProductsPane(widgets.ABAbstractPane): - """ - A widget that displays products related to a specific database. - - Attributes: - database (bd.Database): The database to display products for. - model (ProductModel): The model containing the data for the products. - table_view (ProductView): The view displaying the products. - search (widgets.ABLineEdit): The search bar for quick search. - """ - def __init__(self, parent, db_name: str): - """ - Initializes the DatabaseProductsPane widget. - - Args: - parent (QtWidgets.QWidget): The parent widget. - db_name (str): The name of the database to display products for. - """ - self.name = "database_products_pane_" + db_name - - super().__init__(parent) - - self.database = bd.Database(db_name) - self.title = db_name - self.simple = True - - # initialize the model - self.model = ProductModel(parent=self, chunk_size=20, enable_sorting=True) - - # Create the QTableView and set the model - self.table_view = ProductView(self, db_name=db_name) - self.table_view.setUniformRowHeights(True) - self.table_view.setModel(self.model) - - self.search_bar = widgets.MetaDataAutoCompleteTextEdit(self) - self.search_bar.database_name = db_name - self.search_bar.setMaximumHeight(30) - self.search_bar.setPlaceholderText("Quick Search") - - # Create loading indicator with spinner - self.loading_spinner = QtWidgets.QProgressBar() - self.loading_spinner.setRange(0, 0) # Indeterminate/busy indicator - self.loading_spinner.setTextVisible(False) - self.loading_spinner.setMaximumWidth(200) - self.loading_spinner.setMaximumHeight(20) - - self.loading_label = widgets.ABLabel("Loading database...") - self.loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - font = self.loading_label.font() - font.setPointSize(14) - self.loading_label.setFont(font) - self.loading_label.setStyleSheet("color: gray; padding: 10px;") - - # Create simple/detailed view toggle - self.view_toggle = QtWidgets.QCheckBox("Details") - self.view_toggle.setChecked(not self.simple) - self.view_toggle.setToolTip("Toggle between simple and detailed view") - - self.build_layout() - self.connect_signals() - self.update_loading_state() - self.sync() - - def build_layout(self): - # Create a stacked layout to switch between loading and table view - self.stacked_layout = QtWidgets.QStackedLayout() - - # Page 0: Loading indicator with spinner - loading_widget = QtWidgets.QWidget(self) - loading_layout = QtWidgets.QVBoxLayout(loading_widget) - loading_layout.addStretch() - loading_layout.addWidget(self.loading_spinner, alignment=Qt.AlignmentFlag.AlignCenter) - loading_layout.addWidget(self.loading_label) - loading_layout.addStretch() - self.stacked_layout.addWidget(loading_widget) - - # Page 1: Table view - table_widget = QtWidgets.QWidget(self) - table_layout = QtWidgets.QVBoxLayout(table_widget) - table_layout.setSpacing(0) - table_layout.setContentsMargins(0, 0, 0, 0) - table_layout.addWidget(self.table_view) - self.stacked_layout.addWidget(table_widget) - - # Create top bar with search and toggle - top_bar = QtWidgets.QHBoxLayout() - top_bar.addWidget(self.search_bar) - top_bar.addWidget(self.view_toggle) - - layout = QtWidgets.QVBoxLayout(self) - layout.addLayout(top_bar) - layout.addLayout(self.stacked_layout) - - # Set the table view as the central widget of the window - self.setLayout(layout) - - def connect_signals(self): - app.signals.metadata.synced.connect(self.on_metadata_changed) - app.signals.database.deleted.connect(self.on_database_deleted) - - self.view_toggle.checkStateChanged.connect(self.on_mode_switch) - self.search_bar.textChangedDebounce.connect(self.sync) - - def on_metadata_changed(self, added, updated, deleted): - # Check if primary data has finished loading - self.update_loading_state() - - if any(db == self.database.name for db, code in added | updated | deleted): - self.sync() - - def update_loading_state(self): - """ - Updates the loading state based on whether primary metadata has loaded. - Shows the loading indicator if primary data is still loading, otherwise shows the table. - """ - if app.metadata.loader.secondary_status == "done": - # Show table view - self.stacked_layout.setCurrentIndex(1) - else: - # Show loading indicator - self.stacked_layout.setCurrentIndex(0) - - def sync(self): - """ - Synchronizes the widget with the current state of the database. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - t = time() - df = self.build_df() - - if self.search_bar.toPlainText().strip(): - # Reset sorting when searching - self.model.sorted_column = None - self.model.sort_order = Qt.SortOrder.AscendingOrder - - self.model.set_dataframe(df) - - self.update_table_style() - self.update_column_visibility() - - logger.debug(f"Synced DatabaseProductsPane in {time() - t:.2f} seconds") - - def update_table_style(self): - self.table_view.header().setHidden(self.simple) - self.table_view.viewport().setBackgroundRole( - QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) - self.table_view.setFrameShape( - QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) - - def update_column_visibility(self): - columns = self.model.columns() - df = self.model.df - - for index, col in enumerate(columns): - if col == "index": - continue - if col == "node": - self.table_view.setColumnHidden(index, not self.simple) - continue - - if df[col].isna().all() or self.simple: - self.table_view.hideColumn(index) - else: - self.table_view.showColumn(index) - - self.table_view.reset() - - def build_df(self) -> pd.DataFrame: - """ - Builds a DataFrame from the database products. - - Returns: - pd.DataFrame: The DataFrame containing the products data. - """ - t = time() - cols = ["name", "key", "processor", "product", "type", "unit", "location", "id", "categories", "properties"] - - query = self.search_bar.toPlainText().strip() - if query: - df = app.metadata.search_database(query, self.database.name, cols) - else: - df = app.metadata.get_database_metadata(self.database.name, cols) - - processors = set(df["processor"].dropna().unique()) - df = df.drop(processors, errors="ignore") - df.rename(columns={"id": "_id"}, inplace=True) - - if not df.properties.isna().all(): - props_df = df[df.properties.notna()] - props_df = pd.DataFrame(list(props_df.get("properties")), index=props_df.key) - props_df.rename(lambda col: f"property_{col}", axis="columns", inplace=True) - - df = df.merge( - props_df, - left_on="key", - right_index=True, - how="left", - ) - - df["node"] = None - - cols = ["name", "product", "categories", "unit", "location", "key", "processor", "type", "node"] - cols += [col for col in df.columns if col.startswith("property")] - cols += ["_id"] - - logger.debug(f"Built DatabaseProductsPane dataframe in {time() - t:.2f} seconds") - - return df[cols].reset_index(drop=True) - - def on_database_deleted(self, db_name: str): - """ - Handles the database deleted signal by closing the widget if the database is deleted. - - Args: - db_name (str): The name of the deleted database. - """ - if db_name == self.database.name: - self.deleteLater() - - def on_mode_switch(self, check: Qt.CheckState): - """ - Handles the mode switch between simple and detailed view. - - Args: - check (Qt.CheckState): The check state of the toggle. - """ - self.simple = check == Qt.CheckState.Unchecked - self.update_table_style() - self.update_column_visibility() - - -class ProductView(ui.widgets.ABTreeView): - """ - A view that displays the products in a tree structure. - - Attributes: - defaultColumnDelegates (dict): The default column delegates for the view. - """ - defaultColumnDelegates = { - "categories": delegates.ListDelegate, - "key": delegates.StringDelegate, - "processor": delegates.StringDelegate, - "node": delegates.CardDelegate, - } - - class ContextMenu(ui.widgets.ABMenu): - menuSetup = [ - lambda m, p: m.add(app.actions.ActivityOpen, p.selected_activities, - text="Open process" if len(p.selected_activities) == 1 else "Open processes", - enable=len(p.selected_activities) > 0 - ), - lambda m: m.addSeparator(), - lambda m, p: m.add(app.actions.ActivityNewProcess, p.db_name, - enable=not database_is_locked(p.db_name), - ), - lambda m, p: m.add(app.actions.ActivityDuplicate, p.selected_activities, - text="Duplicate process" if len(p.selected_activities) == 1 else "Duplicate processes", - enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), - ), - lambda m, p: m.add(app.actions.ActivityDuplicateToDB, p.selected_activities, - text="Duplicate process to database" if len(p.selected_activities) == 1 else "Duplicate processes to database", - enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), - ), - lambda m: m.addSeparator(), - lambda m, p: m.add(app.actions.ActivityDelete, p.selected_activities, - text="Delete process" if len(p.selected_activities) == 1 else "Delete processes", - enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), - ), - lambda m, p: m.add(app.actions.ActivityDelete, p.selected_products, - text="Delete product" if len(p.selected_products) == 1 else "Delete products", - enable=len(p.selected_products) > 0 and not - database_is_locked(p.db_name) and not - database_is_legacy(p.db_name), - ), - lambda m: m.addSeparator(), - lambda m, p: m.add(app.actions.CSNew, - functional_units=[{prod: m.get_functional_unit_amount(prod)} for prod in p.selected_products], - enable=len(p.selected_products) > 0, - text="Create setup" - ), - lambda m, p: m.add(app.actions.ActivitySDFToClipboard, p.selected_products, - enable=len(p.selected_products) > 0, - ), - ] - - @staticmethod - def get_functional_unit_amount(key): - from activity_browser.bwutils.commontasks import refresh_node - excs = list(refresh_node(key).upstream(["production"])) - exc = excs[0] if len(excs) == 1 else {} - return exc.get("amount", 1.0) - - def __init__(self, parent: DatabaseProductsPane, db_name: str): - """ - Initializes the ProductView. - - Args: - parent (DatabaseProductsPane): The parent widget. - db_name (str): The name of the database. - """ - super().__init__(parent) - self.setSortingEnabled(True) - self.setDragEnabled(True) - self.setAcceptDrops(True) - self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DragDrop) - self.setSelectionBehavior(ui.widgets.ABTreeView.SelectionBehavior.SelectRows) - self.setSelectionMode(ui.widgets.ABTreeView.SelectionMode.ExtendedSelection) - - self.db_name = db_name - self.pane = parent - - self.propertyDelegate = delegates.PropertyDelegate(self) - self.overlay = None - - def setDefaultColumnDelegates(self): - """ - Sets the default column delegates for the view. - """ - super().setDefaultColumnDelegates() - - columns = self.model().columns() - for i, col_name in enumerate(columns): - if not col_name.startswith("property_"): - continue - # Set the delegate for property columns - self.setItemDelegateForColumn(i, self.propertyDelegate) - - def mouseDoubleClickEvent(self, event) -> None: - """ - Handles the mouse double click event to open the selected activities. - - Args: - event: The mouse double click event. - """ - if self.selected_activities: - app.actions.ActivityOpen.run(self.selected_activities) - - def keyPressEvent(self, event) -> None: - """ - Handles key press events. Specifically handles Ctrl+C to copy selected data. - - Args: - event: The key press event. - """ - if event.modifiers() & Qt.KeyboardModifier.ControlModifier: - if event.key() == Qt.Key.Key_C: # Copy - self.copy_selection_to_clipboard() - return - if event.key() == Qt.Key.Key_V: - self.copy_from_clipboard() - if event.key() == Qt.Key.Key_A: # Select All - self.selectAll() - return - if event.key() == Qt.Key.Key_F: # Find - self.pane.search_bar.setFocus() - return - if event.key() == Qt.Key.Key_Delete: - if database_is_locked(self.db_name): - return - if self.selected_products: - app.actions.ActivityDelete.run(self.selected_products) - return - - super().keyPressEvent(event) - - def copy_selection_to_clipboard(self): - selection = self.selectedIndexes() - mime_data = self.model().mimeData(selection) - - clipboard = QtWidgets.QApplication.clipboard() - clipboard.setMimeData(mime_data) - - def copy_from_clipboard(self): - if database_is_locked(self.db_name): - return - - clipboard = QtWidgets.QApplication.clipboard() - mime_data = clipboard.mimeData() - - if mime_data.hasFormat("application/bw-nodekeylist"): - keys: list = mime_data.retrievePickleData("application/bw-nodekeylist") - keys = list(set(keys)) - - app.actions.ActivityDuplicateToDB.run(keys, self.db_name) - - def dragEnterEvent(self, event): - """ - Handles the drag enter event. - - Args: - event: The drag enter event. - """ - if event.source() == self: - return - - if database_is_locked(self.db_name): - return - - if event.mimeData().hasFormat("application/bw-nodekeylist"): - keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - - if any(is_node_biosphere(key) for key in keys): - return - - self.overlay = widgets.ABDropOverlay(self, text="Drop here to duplicate to this database") - self.overlay.show() - event.accept() - - def dragMoveEvent(self, event): - pass - - def dragLeaveEvent(self, event): - """ - Handles the drag leave event. - - Args: - event: The drag leave event. - """ - if self.overlay: - self.overlay.deleteLater() - - def dropEvent(self, event): - """ - Handles the drop event. - - Args: - event: The drop event. - """ - logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") - # Reset the palette on drop - if self.overlay: - self.overlay.deleteLater() - - keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") - keys = list(set(keys)) - - app.actions.ActivityDuplicateToDB.run(keys, self.db_name) - - @property - def selected_products(self) -> list[tuple]: - """ - Returns the selected products. - - Returns: - list[tuple]: The list of selected products. - """ - keys = self.model().values_from_indices("key", self.selectedIndexes()) - types = self.model().values_from_indices("type", self.selectedIndexes()) - - return list({key for key, type in zip(keys, types) if not type == "nonfunctional"}) - - @property - def selected_activities(self) -> list[tuple]: - """ - Returns the selected activities. - - Returns: - list[tuple]: The list of selected activities. - """ - processors = self.model().values_from_indices("processor", self.selectedIndexes()) - keys = self.model().values_from_indices("key", self.selectedIndexes()) - - return list({processor if not pd.isna(processor) else key for processor, key in zip(processors, keys)}) - - -class ProductModel(ui.core.ABTreeModel): - #-- flag overrides --- - def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: - return True - - # -- data overrides --- - def displayData(self, index: QModelIndex) -> any: - column_name = self.column_name(index) - if column_name != "node": - return super().displayData(index) - - row = self.row(index) - - if row is None: - return None - - # Get the product or name for title - title = row.get("product") or row.get("name") - - # Build subtitle with name (if product exists) or type - subtitle_parts = [] - if row.get("product") and row.get("name"): - # If there's both product and name, show name as subtitle - subtitle_parts.append(row.get("name")) - elif row.get("type"): - # Otherwise show type - subtitle_parts.append(row.get("type").capitalize()) - - subtitle = " | ".join(subtitle_parts) if subtitle_parts else None - - # Build categories list from unit, location, database - categories = [] - if row.get("unit"): - categories.append(str(row.get("unit"))) - if row.get("location"): - categories.append(str(row.get("location"))) - if row.get("key") and isinstance(row.get("key"), tuple): - categories.append(str(row.get("key")[0])) # database name - - # Add actual categories if they exist - node_categories = row.get("categories") - if node_categories and isinstance(node_categories, (list, tuple)): - categories.extend([str(cat) for cat in node_categories if str(cat).strip()]) - - return { - "title": title, - "subtitle": subtitle, - "categories": categories if categories else None, - } - - def decorationData(self, index: QtCore.QModelIndex) -> any: - column_name = self.column_name(index) - node_type = self.get(index, "type") - - if column_name not in ["name", "product", "node"]: - return None - - if column_name == "name" and node_type in ["product", "waste"]: - return icons.qicons.process - if column_name in ["name", "node"] and node_type == "processwithreferenceproduct": - return icons.qicons.processproduct - if column_name in ["name", "node"] and node_type in NODETYPES["biosphere"]: - return icons.qicons.biosphere - if column_name == "name": - return icons.qicons.empty - - if column_name in ["product", "node"] and node_type in ["product", "processwithreferenceproduct"]: - return icons.qicons.product - if column_name in ["product", "node"] and node_type == "waste": - return icons.qicons.waste - return icons.qicons.empty - - def toolTipData(self, index: QtCore.QModelIndex) -> str: - column_name = self.column_name(index) - if column_name not in ["name", "product"]: - return None - - row = self.row(index) - - html_tooltip = f""" - {row.get('product')}
- {row.get('name')}
-
- {row.get('unit')} | {row.get('location')} | {row.get('type')} - """ - - return html_tooltip - - def mimeData(self, indices: list[QtCore.QModelIndex]): - """ - Returns the mime data for the given indices. - - Args: - indices (list[QtCore.QModelIndex]): The indices to get the mime data for. - - Returns: - core.ABMimeData: The mime data. - """ - data = core.ABMimeData() - keys = set(self.values_from_indices("key", indices)) - keys.update(self.values_from_indices("processor", indices)) - keys = {key for key in keys if isinstance(key, tuple)} - data.setPickleData("application/bw-nodekeylist", list(keys)) - - # Add HTML data for Excel with bold formatting - thread = threading.Thread(target=self.set_excel_nodes_threaded, args=(data, keys)) - thread.start() - - return data - - @staticmethod - def set_excel_nodes_threaded(data, keys): - excel_string = nodes_to_excel(list(keys)) - try: - data.setHtml(excel_string) - except RuntimeError: - pass diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py deleted file mode 100644 index 92b4d1ece..000000000 --- a/activity_browser/app/panes/databases.py +++ /dev/null @@ -1,327 +0,0 @@ -import datetime -from loguru import logger - -from qtpy import QtWidgets, QtGui, QtCore -from qtpy.QtCore import Qt - -import bw2data as bd -import pandas as pd - -from activity_browser import app -from activity_browser.bwutils.commontasks import count_database_records -from activity_browser.ui import widgets, icons, delegates, core -from activity_browser.app.menu_bar import ImportDatabaseMenu - - -class DatabasesPane(widgets.ABAbstractPane): - """ - A widget that displays the databases and their details. - - Attributes: - view (DatabasesView): The view displaying the databases. - model (DatabasesModel): The model containing the data for the databases. - """ - title = "Databases" - unique = True - - def __init__(self, parent): - """ - Initializes the DatabasesPane widget. - - Args: - parent (QtWidgets.QWidget): The parent widget. - """ - super().__init__(parent) - self._populate_later_flag = False - - self.model = DatabasesModel(parent=self) - self.view = DatabasesView() - self.view.setModel(self.model) - - self.view.setAlternatingRowColors(True) - self.view.setIndentation(0) - - self.build_layout() - self.connect_signals() - - def connect_signals(self): - """ - Connects the signals to the appropriate slots. - """ - app.signals.meta.databases_changed.connect(self.syncLater) - app.signals.metadata.synced.connect(self.syncLater) - app.signals.database.deleted.connect(self.syncLater) - app.signals.database_read_only_changed.connect(self.syncLater) - - def build_layout(self): - """ - Builds the layout of the widget. - """ - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.view) - layout.setContentsMargins(5, 0, 5, 5) - self.setLayout(layout) - - def syncLater(self): - """ - Schedules a sync operation to be performed later. - """ - - def slot(): - self._populate_later_flag = False - self.sync() - self.thread().eventDispatcher().awake.disconnect(slot) - - if self._populate_later_flag: - return - - self._populate_later_flag = True - self.thread().eventDispatcher().awake.connect(slot) - - def sync(self): - """ - Synchronizes the model with the current state of the databases. - """ - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - df = self.build_df() - self.model.set_dataframe(df) - self.view.resizeColumnToContents(1) - self.view.header().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) - - def build_df(self) -> pd.DataFrame: - """ - Builds a DataFrame from the databases. - - Returns: - pd.DataFrame: The DataFrame containing the databases data. - """ - data = [] - for name in bd.databases: - # get the modified time, in case it doesn't exist, just write 'now' in the correct format - dt = bd.databases[name].get("modified", datetime.datetime.now().isoformat()) - dt = datetime.datetime.strptime(dt, "%Y-%m-%dT%H:%M:%S.%f") - - # final column includes interactive checkbox which shows read-only state of db - data.append( - { - "name": name, - "depends": ", ".join(bd.databases[name].get("depends", [])), - "modified": dt, - "records": count_database_records(name), - "read_only": bd.databases[name].get("read_only", True), - "default_allocation": bd.databases[name].get("default_allocation", "unspecified"), - "backend": bd.databases[name].get("backend") - } - ) - - cols = ["read_only", "name", "records", "depends", "default_allocation", "modified", "backend"] - - return pd.DataFrame(data, columns=cols) - - -class DatabasesView(widgets.ABTreeView): - """ - A view that displays the databases in a tree structure. - - Attributes: - defaultColumnDelegates (dict): The default column delegates for the view. - """ - defaultColumnDelegates = { - "modified": delegates.DateTimeDelegate, - } - - class ExportDatabaseContextMenu(widgets.ABMenu): - menuSetup = [ - lambda m: m.setTitle("Export database" if len(m.parent().selected_databases) == 1 else "Export databases"), - lambda m, p: m.add(app.actions.DatabaseExportExcel, p.selected_databases if p.selected_databases else [], - enable=len(p.selected_databases) >= 1, - text="to .xlsx", - ), - lambda m, p: m.add(app.actions.DatabaseExportBW2Package, p.selected_databases if p.selected_databases else [], - enable=len(p.selected_databases) >= 1, - text="to .bw2package", - ), - ] - - class ContextMenu(widgets.ABMenu): - menuSetup = [ - lambda m, p: m.add(app.actions.DatabaseNew), - lambda m: m.addMenu(ImportDatabaseMenu(m)), - lambda m, p: m.addMenu(DatabasesView.ExportDatabaseContextMenu(parent=p)), - lambda m: m.addSeparator(), - lambda m, p: m.add(app.actions.DatabaseDelete, p.selected_databases if p.selected_databases else [], - enable=len(p.selected_databases) >= 1, - text="Delete databases" if len(p.selected_databases) > 1 else "Delete database", - ), - lambda m, p: m.add(app.actions.DatabaseDuplicate, p.selected_databases[0] if p.selected_databases else None, - enable=len(p.selected_databases) == 1), - lambda m, p: m.add(app.actions.DatabaseRelink, p.selected_databases[0] if p.selected_databases else None), - lambda m, p: m.add(app.actions.DatabaseProcess, p.selected_databases[0] if p.selected_databases else None, - enable=len(p.selected_databases) == 1), - lambda m: m.addSeparator(), - lambda m, p: m.add(app.actions.DatabaseSetReadonly, p.selected_databases[0] if p.selected_databases else None, - not m.selected_readonly, - enable=len(p.selected_databases) == 1, - text="Unlock database" if m.selected_readonly else "Lock database", - ), - ] - - @property - def selected_readonly(self): - """ - Returns the read-only state of the selected database. - - Returns: - bool: The read-only state of the selected database. - """ - if not self.parent().selected_databases: - return None - index = self.parent().selectedIndexes()[0] - row = self.parent().model().row(index) - return row.get("read_only") if row is not None else None - - class HeaderMenu(QtWidgets.QMenu): - """ - A header menu for the DatabasesView. Currently not used. - """ - - def __init__(self, *args, **kwargs): - super().__init__() - - def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent): - """ - Handles the mouse double click event to toggle the read-only state or select the database. - - Args: - event (QtGui.QMouseEvent): The mouse double click event. - """ - index = self.indexAt(event.pos()) - - if not index.isValid(): - return super().mouseDoubleClickEvent(event) - - row = self.model().row(index) - if row is None: - return super().mouseDoubleClickEvent(event) - - db_name = row.get("name") - - if index.column() == 1: - read_only = row.get("read_only") - app.actions.DatabaseSetReadonly.run(db_name, not read_only) - return - - app.actions.DatabaseOpen.run([db_name]) - - def keyPressEvent(self, event: QtGui.QKeyEvent): - """ - Handles key press events. Specifically handles the Delete key to delete selected databases. - - Args: - event (QtGui.QKeyEvent): The key press event. - """ - if event.key() == Qt.Key_Delete: - if self.selected_databases: - app.actions.DatabaseDelete.run(self.selected_databases) - return - - super().keyPressEvent(event) - - @property - def selected_databases(self) -> list: - """ - Returns the database name of the user-selected index. - - Returns: - str: The name of the selected database. - """ - if not self.selectedIndexes(): - return [] - names = self.model().values_from_indices("name", self.selectedIndexes()) - return list(set(names)) - - -class DatabasesModel(core.ABTreeModel): - """ - A model representing the data for the databases. - """ - - def decorationData(self, index: QtCore.QModelIndex) -> any: - """ - Provides decoration data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide decoration data. - - Returns: - The decoration data for the index. - """ - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return None - - if column_name == "read_only": - return icons.qicons.locked if row.get("read_only") else icons.qicons.empty - - return None - - def displayData(self, index: QtCore.QModelIndex) -> any: - """ - Provides display data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide display data. - - Returns: - The display data for the index. - """ - column_name = self.column_name(index) - row = self.row(index) - - if row is None: - return None - - if column_name == "read_only": - return None - - return row.get(column_name) - - def fontData(self, index: QtCore.QModelIndex) -> any: - """ - Provides font data for the model. - - Args: - index (QtCore.QModelIndex): The index for which to provide font data. - - Returns: - QtGui.QFont: The font data for the index. - """ - column_name = self.column_name(index) - - if column_name == "name": - font = QtGui.QFont() - font.setWeight(QtGui.QFont.Weight.DemiBold) - return font - - return None - - def headerData(self, section, orientation=Qt.Orientation.Horizontal, role=Qt.ItemDataRole.DisplayRole): - """ - Provides header data for the model. - - Args: - section (int): The section index. - orientation (Qt.Orientation): The orientation of the header. - role (Qt.ItemDataRole): The role for which to provide header data. - - Returns: - The header data for the model. - """ - if section == 1 and role == Qt.ItemDataRole.DisplayRole: - return "" - if section == 1 and role == Qt.ItemDataRole.DecorationRole: - return icons.qicons.unlocked - return super().headerData(section, orientation, role) diff --git a/activity_browser/app/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py deleted file mode 100644 index f909a8581..000000000 --- a/activity_browser/app/panes/impact_categories.py +++ /dev/null @@ -1,178 +0,0 @@ -from qtpy import QtWidgets, QtCore -from loguru import logger - -import bw2data as bd -import pandas as pd - -from activity_browser import app, app -from activity_browser.ui import widgets, core, delegates - - -class ImpactCategoriesPane(widgets.ABAbstractPane): - title = "Impact Categories" - unique = True - - def __init__(self, parent=None): - super().__init__(parent) - self.model = ImpactCategoriesModel(parent=self) - self.view = ImpactCategoriesView() - self.view.setModel(self.model) - - self.view.setSelectionMode(QtWidgets.QTableView.SingleSelection) - self.view.setDragEnabled(True) - self.view.setDragDropMode(QtWidgets.QTableView.DragDropMode.DragOnly) - self.view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - - self.search = widgets.ABLineEdit(self) - self.search.setMaximumHeight(30) - self.search.setPlaceholderText("Quick Search") - - self.search.textChangedDebounce.connect(self.view.setAllFilter) - - self.build_layout() - self.connect_signals() - - def build_layout(self): - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.search) - layout.addWidget(self.view) - layout.setContentsMargins(5, 0, 5, 5) - - self.setLayout(layout) - - def connect_signals(self): - app.signals.meta.methods_changed.connect(self.sync) - app.signals.database_read_only_changed.connect(self.sync) - - def sync(self): - logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") - - df = self.build_df() - 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: - return pd.DataFrame(columns=cols) - - return df[cols] - - -class ImpactCategoriesView(widgets.ABTreeView): - defaultColumnDelegates = { - "groups": delegates.ListDelegate, - } - - class ContextMenu(widgets.ABMenu): - menuSetup = [ - lambda m, p: m.add(app.actions.MethodNew), - lambda m: m.addSeparator(), - lambda m, p: m.add(app.actions.MethodOpen, p.selected_impact_categories, - text="Open impact category" if len(p.selected_impact_categories) == 1 else "Open impact categories", - enable=len(p.selected_impact_categories) > 0 - ), - lambda m, p: m.add(app.actions.MethodDelete, p.selected_impact_categories, - text="Delete impact category" if len( - p.selected_impact_categories) == 1 else "Delete impact categories", - enable=len(p.selected_impact_categories) > 0 - ), - lambda m, p: m.add(app.actions.MethodDuplicate, p.selected_impact_categories, - text="Duplicate impact category", - enable=len(p.selected_impact_categories) == 1 - ), - lambda m, p: m.add(app.actions.MethodRename, p.selected_impact_categories, - text="Rename impact category", - enable=len(p.selected_impact_categories) == 1 - ), - ] - - @property - def selected_impact_categories(self): - if not self.selectedIndexes(): - return [] - - indices = [i for i in self.selectedIndexes() if i.column() == 0] - impact_categories = [] - - for index in indices: - impact_categories.extend(self.model().get_impact_categories(index)) - - return list(set(impact_categories)) - - def mouseDoubleClickEvent(self, event) -> None: - if self.selected_impact_categories: - app.actions.MethodOpen.run(self.selected_impact_categories) - - -class ImpactCategoriesModel(core.ABTreeModel): - """ - A model representing the data for the impact categories. - """ - - def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: - """Enable drag for all items.""" - return True - - def mimeData(self, indices: list[QtCore.QModelIndex]): - """ - Returns the mime data for the given indices. - - Args: - indices (list[QtCore.QModelIndex]): The indices to get the mime data for. - - Returns: - core.ABMimeData: The mime data. - """ - data = core.ABMimeData() - names = [] - - for index in indices: - names += self.get_impact_categories(index) - - data.setPickleData("application/bw-methodnamelist", list(set(names))) - return data - - def get_impact_categories(self, index: QtCore.QModelIndex): - """ - Get all impact category method names for the given index. - - For leaf nodes (full depth paths), returns the single method name. - For branch nodes (partial depth paths), returns all child method names. - - Args: - index: The index to get impact categories for. - - Returns: - list: List of method name tuples. - """ - if not index.isValid(): - return [] - - node = index.internalPointer() - - if not isinstance(node, core.TreeNode): - return [] - - # If this is a leaf node, return its method name - if node.is_leaf: - row = self.row(index) - if row is not None: - return [row["_method_name"]] - return [] - - # If this is a branch node, collect all child method names recursively - ics = [] - for i, child_node in enumerate(node.children): - if i >= node.loaded_count: - break # Only process loaded children - child_index = self.createIndex(i, 0, child_node) - ics += self.get_impact_categories(child_index) - - return ics - diff --git a/activity_browser/app/signalling.py b/activity_browser/app/signalling.py deleted file mode 100644 index 16fd0d744..000000000 --- a/activity_browser/app/signalling.py +++ /dev/null @@ -1,328 +0,0 @@ -from loguru import logger -from time import time - -from qtpy.QtCore import QObject, Signal, SignalInstance, QTimer, QEvent -from blinker import signal as blinker_signal - - - - -class NodeSignals(QObject): - changed: SignalInstance = Signal(object, object) - deleted: SignalInstance = Signal(object) - database_change: SignalInstance = Signal(object, object) - code_change: SignalInstance = Signal(object, object) - - -class EdgeSignals(QObject): - changed: SignalInstance = Signal(object, object) - deleted: SignalInstance = Signal(object) - recalculated: SignalInstance = Signal() - - -class MethodSignals(QObject): - changed: SignalInstance = Signal(object) - deleted: SignalInstance = Signal(object) - renamed: SignalInstance = Signal(tuple, tuple) - - -class ParameterSignals(QObject): - changed: SignalInstance = Signal(object, object) - deleted: SignalInstance = Signal(object) - recalculated: SignalInstance = Signal() - - -class DatabaseSignals(QObject): - written: SignalInstance = Signal(object) - reset: SignalInstance = Signal(object) - deleted: SignalInstance = Signal(str) - - -class ProjectSignals(QObject): - changed: SignalInstance = Signal(object, object) # Project changed | new project dataset, old project dataset - created: SignalInstance = Signal() - deleted: SignalInstance = Signal(str) - - -class MetaSignals(QObject): - databases_changed: SignalInstance = Signal(object, object) - methods_changed: SignalInstance = Signal(object, object) - calculation_setups_changed: SignalInstance = Signal(object, object) - - -class MetaDataSignals(QObject): - """Signals for MetaDataStore updates.""" - synced: SignalInstance = Signal(set, set, set) # added, updated, deleted - - def __init__(self, parent=None): - from activity_browser.bwutils.metadata import MetaDataStore - super().__init__(parent) - - self._metadata = MetaDataStore() - self._flusher = QTimer(self, interval=100) - self._flusher.timeout.connect(self._flush_metadata) - self._flusher.start() - - def _flush_metadata(self): - added, updated, deleted = self._metadata.flush_mutations() - - if not (added or updated or deleted): - return - - t = time() - self.synced.emit(added, updated, deleted) - - logger.log("SIGNAL", f"Metadata: synced: {time() - t:.2f} seconds") - -class SettingSignals(QObject): - changed = Signal() # Settings have changed - - def __init__(self, parent=None): - from activity_browser.bwutils.settings import Settings - - super().__init__(parent) - Settings().changed.connect(self.emit_changed) - - def emit_changed(self, *args, **kwargs): - """Emit the changed signal.""" - t = time() - self.changed.emit() - logger.log("SIGNAL", f"Settings: changed: {time() - t:.2f} seconds") - - -class ABSignals(QObject): - """Signals used for the Activity Browser should be defined here. - While arguments can be passed to signals, it is good practice not to do this if possible. - Every signal should have a comment (no matter how descriptive the name of the signal) that describes what a - signal is used for and after a pipe (|), what variables are sent, if any. - """ - node = NodeSignals() - edge = EdgeSignals() - method = MethodSignals() - database = DatabaseSignals() - project = ProjectSignals() - meta = MetaSignals() - metadata = MetaDataSignals() - parameter = ParameterSignals() - settings = SettingSignals() - - # import_project = Signal() # Import a project - # export_project = Signal() # Export the current project - database_selected = Signal(str) # This database was selected (opened) | name of database - database_read_only_changed = Signal(str, bool) # The read_only state of database changed | name of database, read-only state - # database_tab_open = Signal(str) # This database tab is being viewed by user | name of database - # add_activity_to_history = Signal(tuple) - # safe_open_activity_tab = Signal(tuple) # Open activity details tab in read-only mode | key of activity - # unsafe_open_activity_tab = Signal(tuple) # Open activity details tab in editable mode | key of activity - # close_activity_tab = Signal(tuple) # Close this activity details tab | key of activity - # open_activity_graph_tab = Signal(tuple) # Open the graph-view tab | key of activity - # edit_activity = Signal(str) # An activity in this database may now be edited | name of database - # added_parameter = Signal(str, str, str) # This parameter has been added | name of the parameter, amount, type (project, database or activity) - # parameters_changed = Signal() # The parameters have changed - # parameter_scenario_sync = Signal(int, object, bool) # Synchronize this data for table | index of the table, dataframe with scenario data, include default scenario - # parameter_superstructure_built = Signal(int, object) # Superstructure built from scenarios | index of the table, dataframe with scenario data - # set_default_calculation_setup = Signal() # Show the default (first) calculation setup - # calculation_setup_changed = Signal() # Calculation setup was changed - # calculation_setup_selected = Signal(str) # This calculation setup was selected (opened) | name of calculation setup - # lca_calculation = Signal(dict) # Generate a calculation setup | dictionary with name, type (simple/scenario) and potentially scenario data - # delete_method = Signal(tuple, str) # Delete this method | tuple of impact category, level of tree OR the proxy - # method_selected = Signal(tuple) # This method was selected (opened) | tuple of method - monte_carlo_finished = Signal() # The monte carlo calculations are finished - # new_statusbar_message = Signal(str) # Update the statusbar this message | message - # restore_cursor = Signal() # Restore the cursor to normal - # project_updates_available = Signal(str, int) # Project name and number of updates available - # toggle_show_or_hide_tab = Signal(str) # Show/Hide the tab with this name | name of tab - # show_tab = Signal(str) # Show this tab | name of tab - # hide_tab = Signal(str) # Hide this tab | name of tab - # hide_when_empty = Signal() # Show/Hide tab when it has/does not have sub-tabs - plugin_selected = Signal(str, bool) # This plugin was/was not selected | name of plugin, selected state - - def __getattribute__(self, item): - """Delayed loading of connecting to the brighway signals""" - setattr(ABSignals, "__getattribute__", super().__getattribute__) - import bw2data as bd - - self._project_dataset = bd.projects.dataset - - self._connect_bw_signals() - return super().__getattribute__(item) - - def _connect_bw_signals(self): - from bw2data import signals, Method - from bw2data.meta import databases, methods, calculation_setups - - patch_methods_datastore() - patch_projects() - - signals.signaleddataset_on_save.connect(self._on_signaleddataset_on_save) - signals.signaleddataset_on_delete.connect(self._on_signaleddataset_on_delete) - signals.on_activity_database_change.connect(self._on_activity_database_change) - signals.on_activity_code_change.connect(self._on_activity_code_change) - - signals.on_database_delete.connect(self._on_database_delete) - signals.on_database_reset.connect(self._on_database_reset) - signals.on_database_write.connect(self._on_database_write) - - signals.project_changed.connect(self._on_project_changed) - signals.project_created.connect(self._on_project_created) - - signals.on_activity_parameter_recalculate.connect(self._on_parameter_recalculate) - signals.on_database_parameter_recalculate.connect(self._on_parameter_recalculate) - signals.on_project_parameter_recalculate.connect(self._on_parameter_recalculate) - signals.on_activity_parameter_recalculate_exchanges.connect(self._on_parameterized_exchange_recalculate) - - databases._save_signal.connect(self._on_database_metadata_change) - setattr(methods, "_save_signal", blinker_signal("ab.patched_methods")) - methods._save_signal.connect(self._on_methods_metadata_change) - setattr(calculation_setups, "_save_signal", blinker_signal("ab.patched_calculation_setups")) - calculation_setups._save_signal.connect(self._on_cs_metadata_change) - - Method._write_signal.connect(self._on_method_write) - Method._deregister_signal.connect(self._on_method_deregister) - - def _on_signaleddataset_on_save(self, sender, old, new): - from bw2data.backends import ActivityDataset, ExchangeDataset - from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter - - if isinstance(new, ActivityDataset): - t = time() - self.node.changed.emit(new, old) - logger.log("SIGNAL", f"Node: changed: {time() - t:.2f} seconds") - elif isinstance(new, ExchangeDataset): - t = time() - self.edge.changed.emit(new, old) - logger.log("SIGNAL", f"Edge: changed: {time() - t:.2f} seconds") - elif isinstance(new, (ProjectParameter, DatabaseParameter, ActivityParameter)): - t = time() - self.parameter.changed.emit(new, old) - logger.log("SIGNAL", f"Parameter: changed: {time() - t:.2f} seconds") - else: - logger.debug(f"Unknown dataset type changed: {type(new)}") - - def _on_signaleddataset_on_delete(self, sender, old): - from bw2data.backends import ActivityDataset, ExchangeDataset - from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter - - if isinstance(old, ActivityDataset): - t = time() - self.node.deleted.emit(old) - logger.log("SIGNAL", f"Node: deleted: {time() - t:.2f} seconds") - elif isinstance(old, ExchangeDataset): - t = time() - self.edge.deleted.emit(old) - logger.log("SIGNAL", f"Edge: deleted: {time() - t:.2f} seconds") - elif isinstance(old, (ProjectParameter, DatabaseParameter, ActivityParameter)): - t = time() - self.parameter.deleted.emit(old) - logger.log("SIGNAL", f"Parameter: deleted: {time() - t:.2f} seconds") - else: - logger.debug(f"Unknown dataset type deleted: {type(old)}") - - def _on_activity_database_change(self, sender, old, new): - t = time() - self.node.database_change.emit(old, new) - logger.log("SIGNAL", f"Node: database_change: {time() - t:.2f} seconds") - - def _on_activity_code_change(self, sender, old, new): - t = time() - self.node.code_change.emit(old, new) - logger.log("SIGNAL", f"Node: code_change: {time() - t:.2f} seconds") - - def _on_database_delete(self, sender, name): - t = time() - self.database.deleted.emit(name) - logger.log("SIGNAL", f"Database: deleted: {time() - t:.2f} seconds") - - def _on_database_reset(self, sender, name): - from bw2data import Database - t = time() - self.database.reset.emit(Database(name)) - logger.log("SIGNAL", f"Database: reset: {time() - t:.2f} seconds") - - def _on_database_write(self, sender, name): - from bw2data import Database - t = time() - self.database.written.emit(Database(name)) - logger.log("SIGNAL", f"Database: written: {time() - t:.2f} seconds") - - def _on_project_changed(self, ds): - t = time() - self.project.changed.emit(ds, self._project_dataset) - self._project_dataset = ds - logger.log("SIGNAL", f"Project: changed: {time() - t:.2f} seconds") - - def _on_project_created(self, ds): - t = time() - self.project.created.emit() - logger.log("SIGNAL", f"Project: created: {time() - t:.2f} seconds") - - def _on_database_metadata_change(self, sender, old, new): - t = time() - self.meta.databases_changed.emit(old, new) - logger.log("SIGNAL", f"Meta: databased_changed: {time() - t:.2f} seconds") - - def _on_methods_metadata_change(self, sender, old, new): - t = time() - self.meta.methods_changed.emit(old, new) - logger.log("SIGNAL", f"Meta: methods_changed: {time() - t:.2f} seconds") - - def _on_cs_metadata_change(self, sender, old, new): - t = time() - self.meta.calculation_setups_changed.emit(old, new) - logger.log("SIGNAL", f"Meta: calculation_setups_changed: {time() - t:.2f} seconds") - - def _on_method_write(self, sender): - t = time() - self.method.changed.emit(sender) - logger.log("SIGNAL", f"Method: changed: {time() - t:.2f} seconds") - - def _on_method_deregister(self, sender): - t = time() - self.method.deleted.emit(sender) - logger.log("SIGNAL", f"Method: deleted: {time() - t:.2f} seconds") - - def _on_parameter_recalculate(self, sender, *args, **kwargs): - t = time() - self.parameter.recalculated.emit() - logger.log("SIGNAL", f"Parameter: recalculated: {time() - t:.2f} seconds") - - def _on_parameterized_exchange_recalculate(self, sender, *args, **kwargs): - t = time() - self.edge.recalculated.emit() - logger.log("SIGNAL", f"Edge: recalculated: {time() - t:.2f} seconds") - - -def patch_methods_datastore(): - from bw2data import Method - - def write(self, data, process=True): - original_write(self, data, process) - self._write_signal.send(self) - - def deregister(self): - original_deregister(self) - self._deregister_signal.send(self) - - original_write = Method.write - original_deregister = Method.deregister - - setattr(Method, "write", write) - setattr(Method, "deregister", deregister) - - setattr(Method, "_write_signal", blinker_signal("ab.patched_method_write")) - setattr(Method, "_deregister_signal", blinker_signal("ab.patched_method_deregister")) - - -def patch_projects(): - from bw2data.project import ProjectManager - - def delete_project(self, name=None, delete_dir=False): - from activity_browser.app import signals - original_delete(self, name, delete_dir) - t = time() - signals.project.deleted.emit(name) - logger.log("SIGNAL", f"Project: deleted: {time() - t:.2f} seconds") - - original_delete = ProjectManager.delete_project - - setattr(ProjectManager, "delete_project", delete_project) diff --git a/activity_browser/bwutils/README.md b/activity_browser/bwutils/README.md deleted file mode 100644 index acbd57bf8..000000000 --- a/activity_browser/bwutils/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# bwutils - -Utility functions and helpers that extend and build upon Brightway2 functionality. - -## Overview - -This module provides a collection of generic methods and utilities that wrap and extend Brightway2 operations. These utilities are used throughout the Activity Browser to avoid code duplication and provide consistent interfaces to Brightway2 functionality. - -## Directory Structure - -- **`ecoinvent_biosphere_versions/`** - Ecoinvent biosphere database version mappings -- **`io/`** - Import/export operations for data interchange -- **`metadata/`** - Metadata loading and caching for quick access -- **`searchengine/`** - Fuzzy search functionality for dataframes -- **`superstructure/`** - Superstructure scenario analysis tools - -## Key Files - -- **`commontasks.py`** - Common Brightway2 operations (database management, activity operations) -- **`errors.py`** - Custom exception classes for Brightway2 operations -- **`exporters.py`** - Export functionality for databases and activities -- **`importers.py`** - Import functionality for various LCA data formats -- **`filesystem.py`** - File system operations for Brightway2 data directories -- **`manager.py`** - High-level management of Brightway2 projects and databases -- **`montecarlo.py`** - Monte Carlo simulation helpers -- **`multilca.py`** - Multi-functional LCA calculation utilities -- **`pedigree.py`** - Pedigree matrix uncertainty handling -- **`sensitivity_analysis.py`** - Global sensitivity analysis tools -- **`settings.py`** - Settings specific to bwutils operations -- **`strategies.py`** - Import strategies and data transformation functions -- **`uncertainty.py`** - Uncertainty analysis utilities -- **`utils.py`** - General utility functions - -## Purpose - -The bwutils module serves as an abstraction layer between the Activity Browser UI and Brightway2, providing: - -1. **Consistency** - Standardized interfaces for common operations -2. **Error Handling** - Graceful handling of Brightway2 exceptions -3. **Extensions** - Additional functionality not provided by Brightway2 -4. **Integration** - Bridging between Qt UI and Brightway2 data structures - -## Usage Pattern - -Import utilities as needed throughout the application: - -```python -from activity_browser.bwutils import commontasks -``` - -## Design Principle - -Keep utilities generic and reusable. These functions should: -- Work with Brightway2 data structures -- Be independent of UI components -- Be testable without requiring a GUI diff --git a/activity_browser/bwutils/__init__.py b/activity_browser/bwutils/__init__.py index 2cdbf9dd5..f15c26be6 100644 --- a/activity_browser/bwutils/__init__.py +++ b/activity_browser/bwutils/__init__.py @@ -3,4 +3,19 @@ bwutils is a collection of methods that build upon brightway2 and are generic enough to provide here so that we avoid re-typing the same code in different parts of the Activity Browser. """ +import bw_functional +from .commontasks import cleanup_deleted_bw_projects as cleanup +from .commontasks import (refresh_node, refresh_node_or_none, refresh_parameter, refresh_edge, refresh_edge_or_none, + parameters_in_scope, exchanges_to_sdf, database_is_locked, database_is_legacy, projects_by_last_opened, + node_group, is_node_product, is_node_biosphere, is_node_process) +from .metadata import AB_metadata +from .montecarlo import MonteCarloLCA +from .multilca import MLCA, Contributions +from .pedigree import PedigreeMatrix +from .sensitivity_analysis import GlobalSensitivityAnalysis +from .superstructure import SuperstructureContributions, SuperstructureMLCA +from .uncertainty import (CFUncertaintyInterface, ExchangeUncertaintyInterface, + ParameterUncertaintyInterface, + get_uncertainty_interface) +from .utils import Parameter diff --git a/activity_browser/bwutils/commontasks.py b/activity_browser/bwutils/commontasks.py index 9853c1998..8b616f899 100644 --- a/activity_browser/bwutils/commontasks.py +++ b/activity_browser/bwutils/commontasks.py @@ -1,13 +1,12 @@ -import os import hashlib import textwrap from datetime import datetime -from loguru import logger +from logging import getLogger from collections import OrderedDict import arrow import pandas as pd -import numpy as np +import peewee as pw import bw2data as bd from bw2data.parameters import ParameterBase, ProjectParameter, DatabaseParameter, ActivityParameter, Group @@ -15,8 +14,11 @@ from functools import lru_cache +from .metadata import AB_metadata from .utils import Parameter +log = getLogger(__name__) + """ bwutils is a collection of methods that build upon brightway2 and are generic enough to provide here so that we avoid re-typing the same code in different parts of the Activity Browser. @@ -103,7 +105,7 @@ def cleanup_deleted_bw_projects() -> None: NOTE: This cannot be done from within the AB. """ n_dir = bd.projects.purge_deleted_directories() - logger.info(f"Deleted {n_dir} unused project directories!") + log.info(f"Deleted {n_dir} unused project directories!") def projects_by_last_opened(): @@ -163,9 +165,8 @@ def count_database_records(name: str) -> int: """To account for possible brightway database types that do not implement the __len__ method. """ - from activity_browser.app import metadata try: - return len(metadata.dataframe.loc[name]) + return len(AB_metadata.dataframe.loc[name]) except KeyError: return 0 @@ -194,14 +195,11 @@ def get_activity_name(key, str_length=22): return ",".join(key.get("name", "").split(",")[:3])[:str_length] -def is_node_product_or_waste(node: tuple | int | bd.Node) -> bool: - return is_node_product(node) or is_node_waste(node) - def is_node_product(node: tuple | int | bd.Node) -> bool: node = refresh_node(node) raw_type = node._document.type - if raw_type in ["product", "processwithreferenceproduct"]: + if raw_type in ["product", "waste", "processwithreferenceproduct"]: return True if raw_type == "process" and len(node.upstream(kinds=["production"])): @@ -209,15 +207,6 @@ def is_node_product(node: tuple | int | bd.Node) -> bool: return False -def is_node_waste(node: tuple | int | bd.Node) -> bool: - node = refresh_node(node) - raw_type = node._document.type - - if raw_type == "waste": - return True - - return False - def is_node_biosphere(node: tuple | int | bd.Node) -> bool: node = refresh_node(node) @@ -236,12 +225,12 @@ def is_node_process(node: tuple | int | bd.Node) -> bool: return False -def refresh_node(node: tuple | int | np.int64 | bd.Node) -> bd.Node: +def refresh_node(node: tuple | int | bd.Node) -> bd.Node: if isinstance(node, bd.Node): node = bd.get_node(id=node.id) elif isinstance(node, tuple): node = bd.get_node(key=node) - elif isinstance(node, (int, np.int64)): + elif isinstance(node, int): node = bd.get_node(id=node) else: raise ValueError("Activity must be either a tuple, int or Node instance") @@ -392,18 +381,16 @@ def identify_activity_type(activity): def generate_copy_code(key: tuple) -> str: """Generate a new code to use when copying an activity""" - from activity_browser.app import metadata - db, code = key - meta = metadata.get_database_metadata(db) + metadata = AB_metadata.get_database_metadata(db) if "_copy" in code: code = code.split("_copy")[0] copies = ( - meta["key"] + metadata["key"] .apply(lambda x: x[1] if code in x[1] and "_copy" in x[1] else None) .dropna() .to_list() - if not meta.empty + if not metadata.empty else [] ) if not copies: @@ -465,7 +452,7 @@ def get_exchanges_in_scenario_difference_file_notation(exchanges): except: # The input activity does not exist. remove the exchange. - logger.error( + log.error( "Something did not work with the following exchange: {}. It was removed from the list.".format( exc ) @@ -507,59 +494,3 @@ def get_LCIA_method_name_dict(keys: list) -> dict: values: brightway2 method tuples """ return {", ".join(key): key for key in keys} - - -# Common tasks -def savefilepath( - default_file_name: str = "AB_file", file_filter: str = "All Files (*.*)" -): - """A central function to get a safe file path.""" - from qtpy import QtWidgets - - safe_name = bd.utils.safe_filename(default_file_name, add_hash=False) - filepath, _ = QtWidgets.QFileDialog.getSaveFileName( - parent=None, - caption="Choose location for saving", - dir=os.path.join(os.path.expanduser("~"), safe_name), - filter=file_filter, - ) - return filepath - - -def get_templates() -> dict: - import platformdirs, os - - base_dir = platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="ActivityBrowser") - template_dir = os.path.join(base_dir, "templates") - os.makedirs(template_dir, exist_ok=True) - - collection = {} - - for file in os.listdir(template_dir): - if file.endswith(".tar.gz"): - collection[file[:-7]] = os.path.join(template_dir, file) - - return collection - -def nodes_to_excel(nodes: list[tuple | int | bd.Node]) -> str: - """Convert a list of nodes to an HTML table suitable for Excel.""" - from .exporters import ABCSVFormatter - nodes = [refresh_node(n) for n in nodes] - databases = set(n["database"] for n in nodes) - if len(databases) > 1: - raise ValueError("All nodes must be from the same database") - db_name = databases.pop() - formatter = ABCSVFormatter(db_name, nodes) - data = formatter.get_formatted_data(sections=["activities", "exchanges"]) - - html_rows = [] - for row in data: - if isinstance(row, list): - # Bold formatting for lists with nowrap - cells = "".join(f'{str(i)}' for i in row) - else: - # Regular formatting for tuples with nowrap - cells = "".join(f'{str(i)}' for i in row) - html_rows.append(f"{cells}") - - return f"{''.join(html_rows)}
" diff --git a/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py b/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py index 949d43fa1..181a61eab 100644 --- a/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py +++ b/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py @@ -1,6 +1,6 @@ import os from zipfile import ZipFile -from loguru import logger +from logging import getLogger from bw2io.importers import Ecospold2BiosphereImporter from bw2io.importers.ecospold2_biosphere import EMISSIONS_CATEGORIES @@ -9,20 +9,9 @@ from activity_browser.mod import bw2data as bd from ...info import __ei_versions__ +from ...utils import sort_semantic_versions - -def sort_semantic_versions(versions, highest_to_lowest: bool = True) -> list: - """Return a sorted (default highest to lowest) list of semantic versions. - - Sorts based on the semantic versioning system. - """ - return list( - sorted( - versions, - key=lambda x: tuple(map(int, x.split("."))), - reverse=highest_to_lowest, - ) - ) +log = getLogger(__name__) def create_default_biosphere3(version) -> None: @@ -30,12 +19,12 @@ def create_default_biosphere3(version) -> None: # format version number to only Major/Minor version = version[:3] - if version == __ei_versions__[0][:3]: - logger.debug(f"Installing biosphere version >{version}<") + if version == sort_semantic_versions(__ei_versions__)[0][:3]: + log.debug(f"Installing biosphere version >{version}<") # most recent version eb = Ecospold2BiosphereImporter() else: - logger.debug(f"Installing legacy biosphere version >{version}<") + log.debug(f"Installing legacy biosphere version >{version}<") # not most recent version, import legacy biosphere from AB eb = ABEcospold2BiosphereImporter(version=version) eb.apply_strategies() @@ -67,7 +56,7 @@ def extract_flow_data(o): lci_dirpath = os.path.join(os.path.dirname(__file__), "legacy_biosphere") # find the most recent legacy biosphere that is equal to or older than chosen version - for ei_version in __ei_versions__: + for ei_version in sort_semantic_versions(__ei_versions__): use_version = ei_version fp = os.path.join( lci_dirpath, f"ecoinvent elementary flows {use_version}.xml.zip" @@ -85,7 +74,7 @@ def extract_flow_data(o): ) as file: root = objectify.parse(file).getroot() - logger.debug(f"Installing biosphere {use_version} for chosen version {version}") + log.debug(f"Installing biosphere {use_version} for chosen version {version}") flow_data = bd.utils.recursive_str_to_unicode( [extract_flow_data(ds) for ds in root.iterchildren()] ) diff --git a/activity_browser/bwutils/exporters.py b/activity_browser/bwutils/exporters.py index 9ec71cf36..b8014c4ab 100644 --- a/activity_browser/bwutils/exporters.py +++ b/activity_browser/bwutils/exporters.py @@ -5,10 +5,11 @@ from typing import Union import xlsxwriter -import bw2data as bd from bw2io.export.csv import reformat from bw2io.export.excel import CSVFormatter, create_valid_worksheet_name +from activity_browser.mod import bw2data as bd + from .importers import ABPackage from .pedigree import PedigreeMatrix diff --git a/activity_browser/bwutils/filesystem.py b/activity_browser/bwutils/filesystem.py deleted file mode 100644 index 6fec07a8e..000000000 --- a/activity_browser/bwutils/filesystem.py +++ /dev/null @@ -1,25 +0,0 @@ -import platformdirs -from pathlib import Path - -import bw2data as bd - - -def get_package_path() -> Path: - path = Path(__file__).resolve().parents[1] - path.mkdir(parents=True, exist_ok=True) - return path - -def get_appdata_path() -> Path: - path = Path(platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="pylca")) - path.mkdir(parents=True, exist_ok=True) - return path - -def get_project_path() -> Path: - path = bd.projects.dir - path.mkdir(parents=True, exist_ok=True) - return path - -def get_project_ab_path() -> Path: - path = Path(bd.projects.dir) / "activity_browser" - path.mkdir(parents=True, exist_ok=True) - return path diff --git a/activity_browser/bwutils/importers.py b/activity_browser/bwutils/importers.py index 69dbb49ea..96bafa27b 100644 --- a/activity_browser/bwutils/importers.py +++ b/activity_browser/bwutils/importers.py @@ -19,13 +19,14 @@ normalize_biosphere_names, normalize_units, set_code_by_activity_hash, strip_biosphere_exc_locations) -import bw2data as bd + +from activity_browser.mod import bw2data as bd from .errors import LinkingFailed from .strategies import (alter_database_name, csv_rewrite_product_key, hash_parameter_group, link_exchanges_without_db, relink_exchanges_bw2package, relink_exchanges_with_db, - rename_db_bw2package, parse_JSON_fields, metadatastore_link, alter_exchange_database_name) + rename_db_bw2package, parse_JSON_fields) class ABExcelImporter(ExcelImporter): @@ -126,7 +127,6 @@ def automated_import(self, db_name: str, relink: dict = None) -> list: excs = [exc for exc in self.unlinked][:10] databases = {exc.get("database", "(name missing)") for exc in self.unlinked} raise StrategyError(excs, databases) - if self.project_parameters: self.write_project_parameters(delete_existing=False) db = self.write_database(delete_existing=True, activate_parameters=True) @@ -134,41 +134,6 @@ def automated_import(self, db_name: str, relink: dict = None) -> list: bd.parameters.recalculate() return [db] - def apply_basic_strategies(self): - self.apply_strategies([ - csv_restore_tuples, - csv_restore_booleans, - csv_numerize, - csv_drop_unknown, - csv_add_missing_exchanges_section, - csv_rewrite_product_key, - normalize_units, - normalize_biosphere_categories, - normalize_biosphere_names, - strip_biosphere_exc_locations, - set_code_by_activity_hash, - drop_falsey_uncertainty_fields_but_keep_zeros, - convert_uncertainty_types_to_integers, - hash_parameter_group, - convert_activity_parameters_to_list, - parse_JSON_fields, - ]) - - def apply_db_name(self, db_name: str): - """Apply a database name change strategy.""" - self.apply_strategy( - functools.partial(alter_database_name, old=self.db_name, new=db_name) - ) - self.db_name = db_name - - def apply_linking(self, relink: dict): - self.apply_strategies([ - link_technosphere_by_activity_hash, # internal linking - functools.partial(alter_exchange_database_name, linking_dict=relink), # change db names - metadatastore_link, # link using metadatastore - ]) - - 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)): diff --git a/activity_browser/bwutils/io/ecoinvent_importer.py b/activity_browser/bwutils/io/ecoinvent_importer.py index 909843617..b584f3c23 100644 --- a/activity_browser/bwutils/io/ecoinvent_importer.py +++ b/activity_browser/bwutils/io/ecoinvent_importer.py @@ -5,7 +5,7 @@ from io import BytesIO from lxml import objectify from functools import partial -from loguru import logger +from logging import getLogger import tqdm import bw2data as bd @@ -35,7 +35,7 @@ update_social_flows_in_older_consequential, ) - +log = getLogger(__name__) class Ecoinvent7zImporter: @@ -72,7 +72,7 @@ def install_ecoinvent(self, db_name, biosphere_name: str = "biosphere3"): """ # if the db already exists, warn the user of the impending overwriting and delete the existing database if db_name in bd.databases: - logger.warning(f"Database already exists, overwriting {db_name}") + log.warning(f"Database already exists, overwriting {db_name}") bd.Database(db_name).delete(warn=False) if self.is_compressed: @@ -123,7 +123,7 @@ def apply_strategies(self, db_data, biosphere_name): return db_data def read_archive_to_bytes(self) -> {str: BytesIO}: - logger.info("Extracting .7z archive to memory") + log.info("Extracting .7z archive to memory") with py7zr.SevenZipFile(self.archive_path, mode='r') as archive: # Find all .spold dataset files file_list = [ @@ -138,7 +138,7 @@ def read_archive_to_bytes(self) -> {str: BytesIO}: def process_bytes(self, spold_bytes: {str: BytesIO}, db_name: str) -> list: with multiprocessing.Pool(processes=multiprocessing.cpu_count()) as pool: - logger.info(f"Extracting XML data from {len(spold_bytes)} datasets") + log.info(f"Extracting XML data from {len(spold_bytes)} datasets") results = [ pool.apply_async( self.extract_activity, diff --git a/activity_browser/bwutils/metadata/README.md b/activity_browser/bwutils/metadata/README.md deleted file mode 100644 index e3ac4b93f..000000000 --- a/activity_browser/bwutils/metadata/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# metadata - -Metadata management for activities, databases, and methods. - -## Overview - -This directory handles storage, retrieval, and management of metadata associated with LCI data in Activity Browser. The MetaDataStore provides quick access to reading node data. - -## Purpose - -Metadata management provides: -- **In memory** - Quicker access to ranges of nodes -- **Unpacked data blob** - Unpack the data blob from the sqlite for quick access -- **Search enhancement** - Fuzzy search capabilities on metadata fields - -## Metadata Types - -See `fields.py` for defined metadata fields and schemas. Common types include: -- **code** - Activity codes -- **name** - Activity names -- **synonyms** - Alternative names - -## Storage -Metadata is cached separately from Brightway2's native storage to allow faster access and searching. It is stored as a pickle on each flush. - -## MetaDataStore - -The `MetaDataStore` class (see `bwutils/metadata/`) provides centralized metadata access: - -```python -from activity_browser import app - -# Access metadata store -metadata = app.metadata - -# Get activity metadata -meta = metadata.get_activity_metadata(activity_key) - -# Update metadata -metadata.update_activity_metadata(activity_key, {"comment": "..."}) -``` - -## Usage Pattern - -### Reading Metadata -```python -meta = metadata.get_metadata(activity_key, fields=["name", "comment"]) -meta = metadata.get_database_metadata(database_name, fields=["description"]) -``` - -### Searching Metadata -```python -results = metadata.search(query="renewable energy") -``` diff --git a/activity_browser/bwutils/metadata/__init__.py b/activity_browser/bwutils/metadata/__init__.py index a49f85c5e..f4aa82ad7 100644 --- a/activity_browser/bwutils/metadata/__init__.py +++ b/activity_browser/bwutils/metadata/__init__.py @@ -1,3 +1 @@ -from .metadata import MetaDataStore - -from . import fields +from .metadata import AB_metadata \ No newline at end of file diff --git a/activity_browser/bwutils/metadata/fields.py b/activity_browser/bwutils/metadata/fields.py index 8a7d6458a..59b885928 100644 --- a/activity_browser/bwutils/metadata/fields.py +++ b/activity_browser/bwutils/metadata/fields.py @@ -1,32 +1,25 @@ primary_types = { "key": object, - "id": "Int64", + "id": "int64", "code": str, - "database": object, - "location": object, + "database": "category", + "location": "category", "name": str, "product": object, - "type": object, + "type": "category", } secondary_types = { "synonyms": object, - "unit": object, - "CAS number": object, + "unit": "category", + "CAS number": "category", "categories": object, "processor": object, - "allocation": object, + "allocation": "category", "allocation_factor": float, "properties": object, } - -search_engine_whitelist = [ - "id", "name", "synonyms", "unit", "key", "database", # generic - "CAS number", "categories", # biosphere specific - "product", "reference product", "classifications", "location", "properties" # activity specific - ] - all_types = {**primary_types, **secondary_types} primary = list(primary_types.keys()) secondary = list(secondary_types.keys()) -all_fields = primary + secondary +all = primary + secondary diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index 7533feac4..c77ef3118 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -1,94 +1,56 @@ +import subprocess import sqlite3 +import sys import pickle -import os -from multiprocessing import Pool -from loguru import logger +from logging import getLogger from typing import Literal + import pandas as pd +import bw2data as bd +from bw2data.backends import sqlite3_lci_db -from qtpy.QtCore import QObject, QThread, Signal, SignalInstance +from qtpy import QtCore -from activity_browser.bwutils.settings import Settings +from activity_browser import signals, application +from activity_browser.ui.core import threading from .metadata import MetaDataStore -from .fields import secondary_types, primary, secondary, search_engine_whitelist, all_fields +from .fields import secondary_types, primary, secondary + +log = getLogger(__name__) -class MDSLoader(QObject): +class MDSLoader(QtCore.QObject): primary_status: Literal["idle", "loading", "done"] = "idle" secondary_status: Literal["idle", "loading", "done"] = "idle" def __init__(self, mds: MetaDataStore): - super().__init__(parent=mds) + super().__init__(mds) self.mds = mds - self.thread: QThread | None = None self.connect_signals() def connect_signals(self): - from bw2data import signals - - # Connect to Brightway's project_changed signal - signals.project_changed.connect(self.on_project_changed) + signals.project.changed.connect(self.on_project_changed) - def on_project_changed(self, sender): - """Called when the Brightway project changes.""" + def on_project_changed(self): self.load_project() def load_project(self): - import bw2data as bd - from bw2data.backends import sqlite3_lci_db - # set statuses self.primary_status = "loading" self.secondary_status = "loading" - # check for valid cache and load from it if available - if self._has_cache() and Settings()["metadatastore"]["caching_enabled"]: - self.cache_load_project() - return - - # start loading thread for secondary metadata - self.thread = SecondaryLoadThread( - databases=list(bd.databases), - sqlite_db=str(sqlite3_lci_db._filepath), - parent=self, - ) - self.thread.result.connect(self.secondary_load_project) - self.thread.start() + # start loading threads + thread = SecondaryLoadThread(self) + thread.setObjectName("SecondaryLoadThread-MDSLoader") + thread.done.connect(self.secondary_load_project) + thread.start(databases=list(bd.databases), sqlite_db=str(sqlite3_lci_db._filepath)) # load primary metadata in the main thread self.primary_load_project() - def cache_load_project(self): - from activity_browser.bwutils import filesystem - - logger.debug("Loading metadata from cache") - - cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" - cached_df = pd.read_pickle(cache_path) - - # quick sanity checks - if not self._cache_check(cached_df): - logger.info("Cache file is invalid or outdated, loading from database instead") - cache_path.unlink() - self.load_project() - return - - self.mds.dataframe = cached_df - - for idx in self.mds.dataframe.index: - self.mds.register_mutation(idx, "add") - - self.primary_status = "done" - self.secondary_status = "done" - - searcher_thread = InitSearcherThread(self.mds, parent=self) - searcher_thread.start() - def primary_load_project(self): - from bw2data.backends import sqlite3_lci_db - with sqlite3.connect(sqlite3_lci_db._filepath) as con: fields = ', '.join(primary[1:]) # Exclude 'key' as it's constructed primary_df = pd.read_sql(f"SELECT {fields} FROM activitydataset", con) @@ -96,7 +58,7 @@ def primary_load_project(self): primary_df["key"] = list(zip(primary_df["database"], primary_df["code"])) primary_df.index = pd.MultiIndex.from_tuples(primary_df["key"], names=["database", "code"]) - logger.debug(f"Primary metadata loaded with {len(primary_df)} rows") + log.debug(f"Primary metadata loaded with {len(primary_df)} rows") self.mds.dataframe = primary_df for idx in primary_df.index: @@ -105,50 +67,28 @@ def primary_load_project(self): self.primary_status = "done" def secondary_load_project(self, secondary_df: pd.DataFrame, sqlite_db: str): - logger.debug("secondary_load_project") - from bw2data.backends import sqlite3_lci_db - if sqlite_db != str(sqlite3_lci_db._filepath): return - assert all(secondary_df.index.isin(self.mds.keys)) - logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") - left = self.mds.get_metadata(columns=primary) - - self.mds.dataframe = pd.concat([left, secondary_df], axis=1) + assert all(secondary_df.index.isin(self.mds.dataframe.index)) + log.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") + self.mds.dataframe = pd.concat([self.mds.dataframe[primary], secondary_df], axis=1) for idx in secondary_df.index: self.mds.register_mutation(idx, "update") self.secondary_status = "done" - searcher_thread = InitSearcherThread(self.mds, parent=self) - searcher_thread.start() - def load_database(self, database_name: str): - from bw2data.backends import sqlite3_lci_db - self.primary_status = "loading" - self.secondary_status = "loading" - - if self.thread is not None and self.thread.isRunning(): - logger.debug("Waiting for previous loading thread to finish") - self.thread.wait() - - # start loading thread for secondary metadata - self.thread = SecondaryLoadThread( - databases=[database_name], - sqlite_db=str(sqlite3_lci_db._filepath), - parent=self, - ) - self.thread.result.connect(self.secondary_load_database) - self.thread.start() + # start loading threads + thread = SecondaryLoadThread(self) + thread.done.connect(self.secondary_load_database) + thread.start(databases=[database_name], sqlite_db=str(sqlite3_lci_db._filepath)) # load primary metadata in the main thread self.primary_load_database(database_name) def primary_load_database(self, database_name: str): - from bw2data.backends import sqlite3_lci_db - with sqlite3.connect(sqlite3_lci_db._filepath) as con: fields = ', '.join(primary[1:]) # Exclude 'key' as it's constructed primary_df = pd.read_sql(f"SELECT {fields} FROM activitydataset WHERE database = '{database_name}'", con) @@ -156,188 +96,69 @@ def primary_load_database(self, database_name: str): primary_df["key"] = list(zip(primary_df["database"], primary_df["code"])) primary_df.index = pd.MultiIndex.from_tuples(primary_df["key"], names=["database", "code"]) - logger.debug(f"Primary metadata loaded with {len(primary_df)} rows") + log.debug(f"Primary metadata loaded with {len(primary_df)} rows") self.mds.dataframe = pd.concat([self.mds.dataframe, primary_df]) for idx in primary_df.index: self.mds.register_mutation(idx, "add") - self.primary_status = "done" - def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): - from bw2data.backends import sqlite3_lci_db - logger.debug("Starting secondary metadata load database callback") - if secondary_df.empty or sqlite_db != str(sqlite3_lci_db._filepath): - self.secondary_status = "done" return database = secondary_df.index[0][0] - indices = self.mds.get_database_metadata(database, []).index + indices = self.mds.dataframe.loc[[database]].index if not all(secondary_df.index.isin(indices)): - logger.debug("Secondary database metadata dropping rows") + log.debug("Secondary database metadata dropping rows") secondary_df = secondary_df[secondary_df.index.isin(indices)] - logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows, adding to metadatastore {id(self.mds)}") + log.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") - df = self.mds.dataframe - self._fix_categories(secondary_df, df) - df = secondary_df.combine_first(df) - self.mds.dataframe = df + self._fix_categories(secondary_df) + self.mds.dataframe.update(secondary_df) for idx in secondary_df.index: self.mds.register_mutation(idx, "update") - if self.mds.searcher is not None: - search_engine_cols = list(set(all_fields) & set(search_engine_whitelist)) - df = self.mds.get_database_metadata(database, search_engine_cols) - for col in df.select_dtypes(include=['category']).columns: - df[col] = df[col].astype(object) - self.mds.searcher.add_identifier(df) - - self.secondary_status = "done" - # utility functions - @staticmethod - def _fix_categories(df: pd.DataFrame, mds_df: pd.DataFrame): + def _fix_categories(self, df: pd.DataFrame): category_columns = [k for k, v in secondary_types.items() if v == "category"] for col in category_columns: categories = df[col].dropna().unique() - categories = [c for c in categories if c not in mds_df[col].cat.categories] + categories = [c for c in categories if c not in self.mds.dataframe[col].cat.categories] # add new category to column - mds_df[col] = mds_df[col].cat.add_categories(categories) - - def _has_cache(self) -> bool: - from activity_browser.bwutils import filesystem + self.mds.dataframe[col] = self.mds.dataframe[col].cat.add_categories(categories) - cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" - lci_path = filesystem.get_project_path() / "lci" / "databases.db" - if not cache_path.exists() or not lci_path.exists(): - return False +class SecondaryLoadThread(threading.ABThread): + done: QtCore.SignalInstance = QtCore.Signal(pd.DataFrame, str) - cache_mtime = cache_path.stat().st_mtime - lci_mtime = lci_path.stat().st_mtime + def run_safely(self, databases: list[str], sqlite_db: str): + processes = [self.open_load_process(db, sqlite_db) for db in databases] - return cache_mtime >= lci_mtime + full_df = pd.DataFrame() + for proc in processes: + stdout_data, stderr_data = proc.communicate() + if proc.returncode != 0: + log.error(f"Error loading metadata: {stderr_data.decode()}") + continue + df = pickle.loads(stdout_data) + if df.empty: + continue - def _cache_check(self, cached_df: pd.DataFrame) -> bool: - import bw2data as bd - from bw2data.backends import sqlite3_lci_db + full_df = pd.concat([full_df, df]) - if not all(db in bd.databases for db in cached_df["database"].unique()): - logger.warning("Cache file contains databases not in the current Brightway project") - return False + self.done.emit(full_df, sqlite_db) - if not len(cached_df) == len(cached_df["id"].unique()): - logger.warning("Cache file contains duplicate IDs") - return False - - if cached_df.empty: - logger.warning("Cache file is empty") - return False - - with sqlite3.connect(sqlite3_lci_db._filepath) as con: - cursor = con.cursor() - cursor.execute("SELECT COUNT(*) FROM activitydataset") - count = cursor.fetchone()[0] + def open_load_process(self, database_name: str, sqlite_db: str) -> subprocess.Popen: + import activity_browser.bwutils.metadata._sub_loader as sl - if count != len(cached_df): - logger.warning("Cache file row count does not match database row count") - return False - - return True - - - -class InitSearcherThread(QThread): - """Thread for initializing the searcher.""" - - def __init__(self, mds: MetaDataStore, parent): - super().__init__(parent=parent) - self.mds = mds - - def run(self): - """Execute the searcher initialization in a background thread.""" - from .searcher import MDSSearcher - - if os.environ.get("AB_NO_SEARCHER"): - logger.debug("Skipping searcher initialization due to AB_NO_SEARCHER environment variable") - return - - if Settings()["metadatastore"]["searcher_enabled"] is False: - logger.debug("Skipping searcher initialization due to settings") - return + return subprocess.Popen( + [sys.executable, sl.__file__, str(sqlite_db), database_name] + secondary, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) - if self.mds.searcher is not None: - old_searcher = self.mds.searcher - self.mds.searcher = None - - # Clear large data structures - if hasattr(old_searcher, 'df'): - del old_searcher.df - if hasattr(old_searcher, 'identifier_to_word'): - del old_searcher.identifier_to_word - if hasattr(old_searcher, 'word_to_identifier'): - del old_searcher.word_to_identifier - if hasattr(old_searcher, 'word_to_q_grams'): - del old_searcher.word_to_q_grams - if hasattr(old_searcher, 'q_gram_to_word'): - del old_searcher.q_gram_to_word - - del old_searcher - - self.mds.searcher = MDSSearcher(self.mds) - - -class SecondaryLoadThread(QThread): - """Thread for loading secondary metadata using multiprocessing Pool.""" - result: SignalInstance = Signal(pd.DataFrame, str) - - def __init__(self, databases: list[str], sqlite_db: str, parent): - super().__init__(parent=parent) - self.databases = databases - self.sqlite_db = sqlite_db - - def run(self): - """Execute the loading in a background thread.""" - try: - if len(self.databases) > 1: - logger.debug(f"Loading metadata from {len(self.databases)} databases using multiprocessing Pool") - with Pool() as pool: - args = [(self.sqlite_db, db, secondary) for db in self.databases] - results = pool.starmap(load, args) - else: - logger.debug("Loading metadata from a single database without multiprocessing") - results = [load(self.sqlite_db, db, secondary) for db in self.databases] - - full_df = pd.DataFrame() - for df in results: - if df is None or df.empty: - continue - full_df = pd.concat([full_df, df]) - - except Exception as e: - logger.error(f"Error loading secondary metadata: {e}", exc_info=True) - full_df = pd.DataFrame() - - self.result.emit(full_df, self.sqlite_db) - - -def load(fp: str, database_name: str, fields: list[str]): - con = sqlite3.connect(fp) - sql = f"SELECT data FROM activitydataset WHERE database = '{database_name}'" - raw_df = pd.read_sql(sql, con) - con.close() - - df = pd.DataFrame([pickle.loads(x) for x in raw_df["data"]]) - if df.empty: - return df - - df["key"] = list(zip(df["database"], df["code"])) - df.index = pd.MultiIndex.from_tuples(df["key"], names=["database", "code"]) - df = df.reindex(columns=fields)[fields] - return df \ No newline at end of file diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index 4d63b315c..ffab3ad23 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -1,33 +1,26 @@ -from typing import Literal, Optional -from loguru import logger - -from qtpy.QtCore import QObject +from time import time +from logging import getLogger +from typing import Literal import pandas as pd -from activity_browser.bwutils.settings import Settings -from .fields import all_fields, all_types +from qtpy.QtCore import Qt, QObject, Signal, SignalInstance, QTimer + +from .fields import all, all_types + + +log = getLogger(__name__) class MetaDataStore(QObject): - """Singleton class to manage metadata storage, loading, updating, and searching.""" - _instance = None - - def __new__(cls, *args, **kwargs): - if cls._instance is None: - cls._instance = super().__new__(cls, *args, **kwargs) - cls._instance._initialized = False - return cls._instance - + synced: SignalInstance = Signal(set, set, set) # added, updated, deleted + def __init__(self, parent=None): + from activity_browser import application from .loader import MDSLoader from .updater import MDSUpdater - from .searcher import MDSSearcher - if self._initialized: - return - self._initialized = True - super().__init__(parent=parent) + super().__init__(parent) self._dataframe = pd.DataFrame() @@ -37,7 +30,9 @@ def __init__(self, parent=None): self.loader = MDSLoader(self) self.updater = MDSUpdater(self) - self.searcher: MDSSearcher | None = None # initialized by the loader + self.flusher: QTimer | None = None + + self.moveToThread(application.thread()) @property def dataframe(self) -> pd.DataFrame: @@ -45,29 +40,15 @@ def dataframe(self) -> pd.DataFrame: @dataframe.setter def dataframe(self, df: pd.DataFrame) -> None: - # Ensure all expected columns are present, in the correct order - df = df.reindex(columns=all_fields)[all_fields] - - # Apply types carefully - avoid in-place modifications - for col, col_type in all_types.items(): - if col in df.columns: - df[col] = df[col].astype(col_type) - - # No NaN values in object columns, use None instead - for col, col_type in all_types.items(): - if col_type != object or col not in df.columns: - continue - df[col] = df[col].where(df[col].notnull(), None) + # Ensure all expected columns are present, in the correct order, and with the correct types + df = df.reindex(columns=all)[all].astype(all_types) + # Set the internal dataframe self._dataframe = df @property def databases(self): - return set(self._dataframe.index.get_level_values(0).unique().tolist()) - - @property - def keys(self): - return set(self._dataframe.index.tolist()) + return set(self.dataframe.get("database", [])) def register_mutation(self, key: tuple[str, str], action: Literal["add", "update", "delete"]): if action == "add": @@ -88,186 +69,48 @@ def register_mutation(self, key: tuple[str, str], action: Literal["add", "update else: raise ValueError(f"Unknown action: {action}") - def flush_mutations(self) -> tuple[set[tuple[str, str]], set[tuple[str, str]], set[tuple[str, str]]]: - from activity_browser.bwutils import filesystem + if not self.flusher: + self.flusher = QTimer(self, interval=100) + self.flusher.timeout.connect(self.flush_mutations) + self.flusher.start() + def flush_mutations(self): if not (self._added or self._updated or self._deleted): - return set(), set(), set() - - added = self._added.copy() - updated = self._updated.copy() - deleted = self._deleted.copy() + return - self._added.clear() - self._updated.clear() - self._deleted.clear() + t = time() + self.synced.emit(self._added, self._updated, self._deleted) - if Settings()["metadatastore"]["caching_enabled"]: - cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" - self._dataframe.to_pickle(cache_path) + self._added.clear(), self._updated.clear(), self._deleted.clear() - return added, updated, deleted + log.debug(f"Metadatastore sync signal completed in {time() - t:.2f} seconds") def match(self, **kwargs: dict[str, str]) -> pd.DataFrame: """Return a slice of the dataframe matching the criteria. """ - df = self._dataframe.query( + df = self.dataframe.query( " and ".join( [ - f"`{key}`.astype('str') == {str(value)!r}" if not pd.isna(value) else f"`{key}`.isnull()" + f"`{key}` == '{value}'" if not pd.isna(value) else f"`{key}`.isnull()" for key, value in kwargs.items() ]) ) return df - def get_metadata(self, keys: list = None, columns: list = None) -> pd.DataFrame: + def get_metadata(self, keys: list, columns: list) -> pd.DataFrame: """Return a slice of the dataframe matching row and column identifiers. NOTE: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#deprecate-loc-reindex-listlike From pandas version 1.0 and onwards, attempting to select a column with all NaN values will fail with a KeyError. """ - keys = keys if keys is not None else self._dataframe.index.tolist() - columns = columns if columns is not None else all_fields - - df = self._dataframe.loc[pd.IndexSlice[keys], :] + df = self.dataframe.loc[pd.IndexSlice[keys], :] return df.reindex(columns, axis="columns") def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFrame: - columns = columns if columns is not None else all_fields - if db_name not in self.databases: - return pd.DataFrame(columns=columns or all_fields) - - df = self._dataframe.loc[[db_name], columns] - return df.reindex(columns, axis="columns") - - def _pandas_search(self, query: str, database: str = None, columns: list = None) -> pd.DataFrame: - """Fallback pandas-based search when searcher is not initialized. - - Args: - query: Search query string, may contain key:value parameters - database: Optional database name to restrict search - columns: Optional list of columns to return - - Returns: - DataFrame with matching results - """ - params, clean_query = get_query_parameters(query) - columns = columns if columns is not None else all_fields - - # Start with the full dataframe or database subset - if database and database in self.databases: - df = self._dataframe.loc[[database]] - else: - df = self._dataframe - - if not clean_query.strip(): - # If no search query, just filter by parameters - if params: - extra_query = " & ".join( - [ - f"`{key}`.astype('str').str.contains('{value}', case=False)" - for key, value in params.items() - if key in df.columns - ] - ) - if extra_query: - df = df.query(extra_query) - return df[columns] + return pd.DataFrame(columns=all) + return self.dataframe.loc[[db_name], columns or all] - # Search across text fields: name, product, synonyms, categories, unit, location - search_fields = ['name', 'product', 'synonyms', 'categories', 'unit', 'location', 'CAS number'] - mask = pd.Series([False] * len(df), index=df.index) - - for field in search_fields: - if field in df.columns: - # Case-insensitive search - mask |= df[field].astype(str).str.contains(clean_query, case=False, na=False) - - df = df[mask] - - # Apply additional parameter filters if any - if params: - extra_query = " & ".join( - [ - f"`{key}`.astype('str').str.contains('{value}', case=False)" - for key, value in params.items() - if key in df.columns - ] - ) - if extra_query: - df = df.query(extra_query) - - return df[columns] if columns else df - - def search(self, query: str, columns: list = None) -> pd.DataFrame: - if self.searcher: - # Advanced searcher is initialized, so use that - params, query = get_query_parameters(query) - result = self.searcher.search(query) - return self._meta_from_result(params, result, columns) - - # Fallback to simple pandas search - logger.debug("Using simple pandas search as searcher is not initialized.") - return self._pandas_search(query, columns=columns) - - def search_database(self, query: str, database: str, columns: list = None) -> pd.DataFrame: - if self.searcher: - params, query = get_query_parameters(query) - result = self.searcher.fuzzy_search(query, database=database) - return self._meta_from_result(params, result, columns) - - # Fallback to simple pandas search - logger.debug(f"Using simple pandas search for database '{database}' as searcher is not initialized.") - return self._pandas_search(query, database=database, columns=columns) - - def _meta_from_result(self, params: dict, result: list[int], columns: list = None) -> pd.DataFrame: - df = self._dataframe.loc[self.dataframe["id"].isin(result), columns or all_fields] - df.sort_values(by="id", inplace=True, key=lambda x: x.map({id_: i for i, id_ in enumerate(result)})) - - extra_query = " & ".join( - [ - f"`{key}`.astype('str').str.contains('{value}', False)" - for key, value in params.items() - if key in df.columns - ] - ) - if extra_query: - df = df.query(extra_query) - - return df - - def auto_complete(self, word: str, context: Optional[set] = None, database: Optional[str] = None): - if not self.searcher: - logger.warning(f"Attempted to search metadata before searcher was initialized.") - return [] - - word = self.searcher.clean_text(word) - completions = self.searcher.auto_complete(word, context=context, database=database) - return completions - - def clear_cache(self): - from activity_browser.bwutils import filesystem - - cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" - if cache_path.exists(): - cache_path.unlink() - logger.info("Metadata store cache cleared.") - else: - logger.info("No metadata store cache found to clear.") - - -def get_query_parameters(query: str) -> tuple[dict[str, str], str]: - """Extract key-value pairs from a query string of the form 'key1:value1 key2:value2'.""" - params = {} - tokens = query.split() - clean_query = [] - for token in tokens: - if ':' in token: - key, value = token.split(':', 1) - params[key] = value - else: - clean_query.append(token) - return params, ' '.join(clean_query) +AB_metadata = MetaDataStore() diff --git a/activity_browser/bwutils/metadata/searcher.py b/activity_browser/bwutils/metadata/searcher.py deleted file mode 100644 index 0f3481849..000000000 --- a/activity_browser/bwutils/metadata/searcher.py +++ /dev/null @@ -1,486 +0,0 @@ -from itertools import permutations -from collections import Counter, OrderedDict -from logging import getLogger -from time import time -from typing import Optional - -import pandas as pd - -from activity_browser.bwutils.searchengine import SearchEngine - -from .metadata import MetaDataStore -from .fields import all_fields - -log = getLogger(__name__) - - -class MDSSearcher(SearchEngine): - - def __init__(self, mds: MetaDataStore): - self.mds = mds - super().__init__(self.mds.dataframe, "id", all_fields) - - # caching for faster operation - def database_id_manager(self, database): - if not hasattr(self, "all_database_ids"): - self.all_database_ids = {} - - if database_ids := self.all_database_ids.get(database): - self.database_ids = database_ids - self.current_database = database - elif database is not None: - self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) - self.all_database_ids[database] = self.database_ids - self.current_database = database - else: - # When database is None, search across all databases - if all_ids := self.all_database_ids.get(None): - self.database_ids = all_ids - else: - self.database_ids = set(self.df.index.to_list()) - self.all_database_ids[None] = self.database_ids - self.current_database = None - return self.database_ids - - def reset_database_id_manager(self): - if hasattr(self, "all_database_ids"): - del self.all_database_ids - if hasattr(self, "database_ids"): - del self.database_ids - - def database_word_manager(self, database): - if not hasattr(self, "all_database_words"): - self.all_database_words = {} - - if database_words := self.all_database_words.get(database): - self.database_words = database_words - elif database is not None: - ids = self.database_id_manager(database) - self.database_words = self.reverse_dict_many_to_one({_id: self.identifier_to_word[_id] for _id in ids}) - self.all_database_words[database] = self.database_words - else: - # When database is None, search across all databases - if all_words := self.all_database_words.get(None): - self.database_words = all_words - else: - ids = self.database_id_manager(database) - self.database_words = self.reverse_dict_many_to_one({_id: self.identifier_to_word[_id] for _id in ids}) - self.all_database_words[None] = self.database_words - return self.database_words - - def reset_database_word_manager(self, database): - if hasattr(self, "all_database_words") and self.all_database_words.get(database): - del self.all_database_words[database] - if hasattr(self, "database_words"): - del self.database_words - - def database_search_cache(self, database, query, result=None): - if not hasattr(self, "search_cache"): - self.search_cache = {} - - if result: - if self.search_cache.get(database): - self.search_cache[database][query] = result - else: - self.search_cache[database] = {query: result} - return - if db_cache := self.search_cache.get(database): - if cached_result := db_cache.get(query): - return cached_result - return - - def reset_search_cache(self, database): - if hasattr(self, "search_cache") and self.search_cache.get(database): - del self.search_cache[database] - - def reset_all_caches(self, databases): - self.reset_database_id_manager() - for database in databases: - self.reset_database_word_manager(database) - self.reset_search_cache(database) - - def add_identifier(self, data: pd.DataFrame) -> None: - super().add_identifier(data) - self.reset_all_caches(data["database"].unique()) - - def remove_identifiers(self, identifiers, logging=True) -> None: - t = time() - - identifiers = set(identifiers) - current_identifiers = set(self.df.index.to_list()) - identifiers = identifiers | current_identifiers # only remove identifiers currently in the data - databases = self.df.loc[identifiers, ["databases"]].unique() # extract databases for cache cleaning - if len(identifiers) == 0: - return - - for identifier in identifiers: - super().remove_identifier(identifier, logging=False) - - if logging: - log.debug(f"Search index updated in {time() - t:.2f} seconds " - f"for {len(identifiers)} removed items ({len(self.df)} items ({self.size_of_index()}) currently).") - self.reset_all_caches(databases) - - def change_identifier(self, identifier, data: pd.DataFrame) -> None: - super().change_identifier(identifier, data) - self.reset_all_caches(data["database"].unique()) - - def auto_complete(self, word: str, context: Optional[set] = set(), database: Optional[str] = None) -> list: - """Based on spellchecker, make more useful for autocompletions - """ - - def word_to_identifier_to_word(check_word): - if len(context) == 0: - return 1 - multiplier = 1 - for identifier in self.word_to_identifier[check_word]: - for context_word in context: - for spell_checked_context_word in spell_checked_context[context_word]: - if spell_checked_context_word in self.identifier_to_word[identifier]: - multiplier += 1 - if context_word not in self.word_to_identifier.keys(): - continue - if context_word in self.identifier_to_word[identifier]: - multiplier += 4 - return multiplier - - # count occurrences of a word, count double so word_to_identifier_to_word will never multiply by 1 - count_occurrence = lambda x: sum(self.word_to_identifier[x].values()) * 2 - - if len(word) <= 1: - return [] - - self.database_id_manager(database) - - if len(context) > 0: - spell_checked_context = {} - for context_word in context: - spell_checked_context[context_word] = self.spell_check(context_word).get(context_word, [])[:5] - - matches_min = 2 # ideally we have at least this many alternatives - matches_max = 4 # ideally don't much more than this many matches - never_accept_this = 4 # values this edit distance or over always rejected - # or max 2/3 of len(word) if less than never_accept_this - never_accept_this = int(round(max(1, min((len(word) * 0.66), never_accept_this)), 0)) - - # first, find possible matches quickly - q_grams = self.text_to_positional_q_gram(word) - possible_matches = self.find_q_gram_matches(set(q_grams), return_all=True) - - first_matches = Counter() - other_matches = {} - probably_keys = Counter() # if we suspect it's a key hash, dump it at the end of the list - - # now, refine with edit distance - for row in possible_matches.itertuples(): - if word == row[1]: - continue - # find edit distance of same size strings - edit_distance = self.osa_distance(word, row[1][:len(word)], cutoff=never_accept_this) - if len(row[1]) == 32 and edit_distance <= 1: - probably_keys[row[1]] = 100 - edit_distance # keys need to be sorted on edit distance, not on occurence - elif edit_distance == 0: - first_matches[row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) - elif edit_distance < never_accept_this and len(first_matches) < matches_min: - if not other_matches.get(edit_distance): - other_matches[edit_distance] = Counter() - other_matches[edit_distance][row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) - else: - continue - - # add matches in correct order: - matches = [match for match, _ in first_matches.most_common()] - # if we have fewer matches than goal, add more 'less good' matches - if len(matches) < matches_min: - for i in range(1, never_accept_this): - # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives - if new := other_matches.get(i): - prev_num = 10e100 - for match, num in new.most_common(): - if num == prev_num: - matches.append(match) - elif num != prev_num and len(matches) <= matches_max: - matches.append(match) - else: - break - prev_num = num - - matches = matches + [match for match, _ in probably_keys.most_common()] - return matches - - def find_q_gram_matches(self, q_grams: set, return_all: bool = False) -> pd.DataFrame: - """Overwritten for extra database specific reduction of results. - """ - n_q_grams = len(q_grams) - - matches = {} - - # find words that match our q-grams - for q_gram in q_grams: - if words := self.q_gram_to_word.get(q_gram, False): - # q_gram exists in our search index - for word in words: - if isinstance(self.database_ids, set): - # DATABASE SPECIFIC now filter on whether word is in the database - in_db = False - for _id in self.word_to_identifier[word]: - if _id in self.database_ids: - in_db = True - break - else: - in_db = True - if in_db: - matches[word] = matches.get(word, 0) + words[word] - - # if we find no results, return an empty dataframe - if len(matches) == 0: - return pd.DataFrame({"word": [], "matches": []}) - - # otherwise, create a dataframe and - # reduce search results to most relevant results - matches = {"word": matches.keys(), "matches": matches.values()} - matches = pd.DataFrame(matches) - max_q = max(matches["matches"]) # this has the most matching q-grams - - # determine how many results we want to keep based on how good our results are - if not return_all: - min_q = min(max(max_q * 0.32, # have at least a third of q-grams of best match or... - max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? - 1)), # okay just do 1 q-gram if there are no more in the word - max_q) # never have min_q be over max_q - else: - min_q = 0 - - matches = matches[matches["matches"] >= min_q] - matches = matches.sort_values(by="matches", ascending=False) - matches = matches.reset_index(drop=True) - - return matches.iloc[:min(len(matches), 2500), :] # return at most this many results - - def search_size_1(self, queries: list, original_words: set, orig_word_weight=5, exact_word_weight=1) -> dict: - """Return a dict of {query_word: Counter(identifier)}. - - queries: is a list of len 1 tuple/lists of words that are a searched word or a 'spell checked' similar word - original words: a list of words actually searched for (not including spellchecked) - - orig_word_weight: additional weight to add to original words - exact_word_weight: additional weight to add to exact word matches (as opposed to be 'in' str) - - First, we find all matching words, creating a dict of words in 'queries' as keys and words matching that query word as list of values - Next, we convert this to identifiers and add weights: - Weight will be increased if matching 'orig_word_weight' or 'exact_word_weight' - """ - matches = {} - t2 = time() - # add each word in search index if query_word in word - for word in self.database_words.keys(): - for query in queries: - # query is list/tuple of len 1 - query_word = query[0] # only use the word - if query_word in word: - words = matches.get(query_word, []) - words.extend([word]) - matches[query_word] = words - - # now convert matched words to matched identifiers - matched_identifiers = {} - for word, matching_words in matches.items(): - if result := self.database_search_cache(self.current_database, word): - matched_identifiers[word] = result - continue - id_counter = matched_identifiers.get(word, Counter()) - for matched_word in matching_words: - weight = self.base_weight - - # add the word n times, where n is the weight, original search word is weighted higher than alternatives - if matched_word in original_words: - weight += orig_word_weight # increase weight for original word - if matched_word == word: - weight += exact_word_weight # increase weight for exact matching word - - id_counter = self.weigh_identifiers(self.database_words[matched_word], weight, id_counter) - matched_identifiers[word] = id_counter - self.database_search_cache(self.current_database, word, matched_identifiers[word]) - - return matched_identifiers - - def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter: bool = False, - logging: bool = True) -> list: - """Overwritten for extra database specific reduction of results. - - Args: - text: Search query string - database: Database name to search within. If None, searches across all databases. - return_counter: If True, return a Counter instead of a list - logging: If True, log search timing information - - Returns: - List of identifiers (or Counter if return_counter=True) matching the search. - """ - t = time() - text = text.strip() - - if len(text) == 0: - log.debug(f"Empty search, returned all items") - if database: - return self.df.loc[self.df["database"] == database].index.to_list() - return self.df.index.to_list() - - # DATABASE SPECIFIC get the set of ids that is in this database - self.database_id_manager(database) - self.database_word_manager(database) - - queries = self.build_queries(text) - - # make list of unique original words - orig_words = OrderedDict() - for word in text.split(" "): - orig_words[word] = False - orig_words = orig_words.keys() - orig_words = {self.clean_text(word) for word in orig_words} - - # order the queries by the amount of words they contain - # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space - queries_by_size = OrderedDict() - longest_query = max([len(q) for q in queries]) - for query_len in range(1, longest_query + 1): - queries_by_size[query_len] = [q for q in queries if len(q) == query_len] - - # first handle queries of length 1 - query_to_identifier = self.search_size_1(queries_by_size[1], orig_words) - - # DATABASE SPECIFIC ensure all identifiers are in the database - if isinstance(self.database_ids, set): - new_q2i = {} - for word, _ids in query_to_identifier.items(): - keep = set.intersection(set(_ids.keys()), self.database_ids) - new_id_counter = Counter() - for _id in keep: - new_id_counter[_id] = _ids[_id] - if len(new_id_counter) > 0: - new_q2i[word] = new_id_counter - query_to_identifier = new_q2i - - # get all results into a df, we rank further later - all_identifiers = set() - for id_list in [id_list for id_list in query_to_identifier.values()]: - all_identifiers.update(id_list) - search_df = self.df.loc[list(all_identifiers)] - - # now, we search for combinations of query words and get only those identifiers - # we then reduce de search_df further for only those matching identifiers - # we then search the permutations of that set of words - for q_len, query_set in queries_by_size.items(): - if q_len == 1: - # we already did these above - continue - for query in query_set: - # get the intersection of all identifiers - # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query - # this ensures we only ever search data where ALL items occur to substantially reduce search-space - # finally, make this a Counter (with each item=1) so we can properly weigh things later - query_id_sets = [set(query_to_identifier.get(q_word)) for q_word in query if - query_to_identifier.get(q_word, False)] - if len(query_id_sets) == 0: - continue - query_identifier_set = set.intersection(*query_id_sets) - if len(query_identifier_set) == 0: - # there is no match for this combination of query words, skip - break - - # now we convert the query identifiers to a Counter of 'occurrence', - # where we weigh queries with only original words higher - query_identifiers = Counter() - for identifier in query_identifier_set: - weight = 0 - for query_word in query: - # if the query_word and identifier combination exist get score, otherwise 0 - weight += query_to_identifier.get(query_word, {}).get(identifier, 0) - - query_identifiers[identifier] = weight - - # we now add these identifiers to a counter for this query name, - query_name = " ".join(query) - - weight = self.base_weight * q_len - query_to_identifier[query_name] = self.weigh_identifiers(query_identifiers, weight, Counter()) - - # now search for all permutations of this query combined with a space - query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] - for query_perm in permutations(query): - query_perm_str = " ".join(query_perm) - if result := self.database_search_cache(self.current_database, query_perm_str): - new_ids = result - else: - mask = self.filter_dataframe(query_df, query_perm_str, search_columns=["query_col"]) - new_df = query_df.loc[mask].reset_index(drop=True) - if len(new_df) == 0: - # there is no match for this permutation of words, skip - continue - new_id_list = new_df[self.identifier_name] - - new_ids = Counter() - for new_id in new_id_list: - new_ids[new_id] = query_identifiers[new_id] - self.database_search_cache(self.current_database, query_perm_str, new_ids) - # we weigh a combination of words that is next also to each other even higher than just the words separately - query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, - query_to_identifier[query_name]) - # now finally, move to one object sorted list by highest score - all_identifiers = Counter() - for identifiers in query_to_identifier.values(): - all_identifiers += identifiers - - if return_counter: - return_this = all_identifiers - else: - # now sort on highest weights and make list type - return_this = [identifier[0] for identifier in all_identifiers.most_common()] - if logging: - log.debug( - f"Found {len(all_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - return return_this - - def search(self, text, database: Optional[str] = None) -> list: - """Search the dataframe on this text, return a sorted list of identifiers. - - Args: - text: Search query string - database: Database name to search within. If None, searches across all databases. - - Returns: - List of identifiers matching the search, sorted by relevance. - """ - t = time() - text = text.strip() - - if len(text) == 0: - log.debug(f"Empty search, returned all items") - return self.df.index.to_list() - - # get the set of ids that is in this database - self.database_id_manager(database) - - fuzzy_identifiers = self.fuzzy_search(text, database=database, logging=False) - if len(fuzzy_identifiers) == 0: - log.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - return [] - - # take the fuzzy search sub-set of data and search it literally - df = self.df.loc[fuzzy_identifiers].copy() - - literal_identifiers = self.literal_search(text, df) - if len(literal_identifiers) == 0: - log.debug( - f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - return fuzzy_identifiers - - # append any fuzzy identifiers that were not found in the literal search - literal_id_set = set(literal_identifiers) - remaining_fuzzy_identifiers = [ - _id for _id in fuzzy_identifiers if _id not in literal_id_set] - identifiers = literal_identifiers + remaining_fuzzy_identifiers - - log.debug( - f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - return identifiers diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index 8e6f7010f..2c969c52f 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -1,73 +1,51 @@ -from loguru import logger +from logging import getLogger import pandas as pd import numpy as np +import timeit -from qtpy.QtCore import QObject +from qtpy import QtCore + +from activity_browser import signals, application from .metadata import MetaDataStore -from .fields import primary, secondary, all_types, search_engine_whitelist +from .fields import primary, secondary, all_types +log = getLogger(__name__) -class MDSUpdater(QObject): +class MDSUpdater(QtCore.QObject): def __init__(self, mds: MetaDataStore): - super().__init__(parent=mds) + super().__init__(mds) + self.mds = mds self.connect_signals() def connect_signals(self): - from bw2data import signals - from bw2data.meta import databases - - # Connect to Brightway signals - signals.signaleddataset_on_save.connect(self.on_signaleddataset_save) - signals.signaleddataset_on_delete.connect(self.on_signaleddataset_delete) - signals.on_database_delete.connect(self.on_database_deleted_bw) - databases._save_signal.connect(self.on_databases_metadata_change) + signals.node.changed.connect(self.on_node_changed) + signals.node.deleted.connect(self.on_node_deleted) + + signals.meta.databases_changed.connect(self.on_database_changed) + signals.database.deleted.connect(self.on_database_changed) # callbacks - def on_signaleddataset_save(self, sender, old, new): - """Called when a dataset is created or modified in Brightway.""" - from bw2data.backends import ActivityDataset - - # Only process ActivityDataset (nodes), not exchanges or parameters - if not isinstance(new, ActivityDataset): - return - + def on_node_changed(self, new, old): node_data = {f: getattr(new, f) for f in primary} - node_data = node_data | {f: new.data.get(f, np.nan) for f in secondary} + node_data = node_data | {f: new.data.get(f, np.NaN) for f in secondary} node_data["key"] = new.key node_data = pd.Series(node_data, name=new.key) if new.key in self.mds.dataframe.index and not all(node_data.dropna().eq(self.mds.dataframe.loc[new.key].dropna())): self.modify_node(node_data) - elif new.key not in self.mds.dataframe.index: + else: self.add_node(node_data) - def on_signaleddataset_delete(self, sender, old): - """Called when a dataset is deleted in Brightway.""" - from bw2data.backends import ActivityDataset - - # Only process ActivityDataset (nodes), not exchanges or parameters - if not isinstance(old, ActivityDataset): - return - + def on_node_deleted(self, ds): try: - # Create a Series with the key to match the delete_node signature - ds = pd.Series({"key": old.key, "id": old.id}, name=old.key) self.delete_node(ds) except KeyError: pass - def on_database_deleted_bw(self, sender, name): - """Called when a database is deleted in Brightway.""" - self.delete_database(name) - - def on_databases_metadata_change(self, sender, old, new): - """Called when the databases metadata changes (e.g., new database added).""" - self.on_database_changed() - def on_database_changed(self) -> None: databases = databases_in_sqlite() @@ -79,73 +57,31 @@ def on_database_changed(self) -> None: # node methods def modify_node(self, ds: pd.Series): - df = self.mds.dataframe - self._fix_categories(ds, df) - df.loc[ds.key] = ds - - self.mds.dataframe = df + self._fix_categories(ds) + self.mds.dataframe.loc[ds.key] = ds self.mds.register_mutation(ds.key, "update") - if not hasattr(self.mds, "searcher") or self.mds.searcher is None: - return - - search_engine_cols = list(set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns - data = pd.DataFrame([ds[search_engine_cols]]) - self.mds.searcher.change_identifier(identifier=ds["id"], data=data) - def add_node(self, ds: pd.Series): - - df = self.mds.dataframe - self._fix_categories(ds, df) - df.loc[ds.key, :] = ds - - self.mds.dataframe = df + self._fix_categories(ds) + self.mds.dataframe.loc[ds.key, :] = ds self.mds.register_mutation(ds.key, "add") - if self.mds.searcher is None: - return - - search_engine_cols = list(set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns - data = pd.DataFrame([ds[search_engine_cols]]) - self.mds.searcher.add_identifier(data=data) - def delete_node(self, ds: pd.Series): self.mds.dataframe = self.mds.dataframe.drop(ds.key) self.mds.register_mutation(ds.key, "delete") - if self.mds.searcher is None: - return - - node_id = ds["id"] - - self.mds.searcher.remove_identifier(identifier=node_id) - self.mds.searcher.reset_all_caches(ds["database"]) - # database methods def add_database(self, db_name: str): self.mds.loader.load_database(db_name) def delete_database(self, db_name: str): - if db_name not in self.mds.databases: - return - for code in self.mds.dataframe.loc[db_name].index: self.mds.register_mutation((db_name, code), "delete") - ids = self.mds.get_database_metadata(db_name, ["id"])["id"].tolist() - self.mds.dataframe = self.mds.dataframe.drop(db_name, level=0) - if self.mds.searcher is None: - return - - for node_id in ids: - self.mds.searcher.remove_identifier(identifier=node_id) - self.mds.searcher.reset_all_caches(db_name) - # utility functions - @staticmethod - def _fix_categories(ds: pd.Series, mds_df: pd.DataFrame): + def _fix_categories(self, ds: pd.Series): for category_col in [k for k, v in all_types.items() if k in ds and v == "category"]: category = ds[category_col] @@ -153,12 +89,12 @@ def _fix_categories(ds: pd.Series, mds_df: pd.DataFrame): # cannot add NaN as a category continue - if category in mds_df[category_col].cat.categories: + if category in self.mds.dataframe[category_col].cat.categories: # category already exists continue # add new category to column - mds_df[category_col] = mds_df[category_col].cat.add_categories([category]) + self.mds.dataframe[category_col] = self.mds.dataframe[category_col].cat.add_categories([category]) diff --git a/activity_browser/bwutils/montecarlo.py b/activity_browser/bwutils/montecarlo.py index c6b96a952..09b4e91df 100644 --- a/activity_browser/bwutils/montecarlo.py +++ b/activity_browser/bwutils/montecarlo.py @@ -1,13 +1,19 @@ from collections import defaultdict from time import time from typing import Optional, Union -from loguru import logger +from logging import getLogger -import bw2data as bd import bw2calc as bc import bw2data as bd import numpy as np import pandas as pd +from stats_arrays import MCRandomNumberGenerator + +from activity_browser.mod import bw2data as bd + +from .manager import MonteCarloParameterManager + +log = getLogger(__name__) class MonteCarloLCA(object): @@ -81,8 +87,8 @@ def construct_lca( characterization: bool = True, seed_override: Optional[int] = None, ) -> bc.MultiLCA: - logger.info(f"Monte Carlo demands: {demands}") - logger.info(f"Monte Carlo impact categories: {method_config}") + log.info(f"Monte Carlo demands: {demands}") + log.info(f"Monte Carlo impact categories: {method_config}") demands = { index: {bd.get_activity(k).id: v for k, v in fu.items()} for index, fu in demands.items() @@ -301,7 +307,7 @@ def calculate(self, iterations: int = 10, seed: Optional[int] = None, **kwargs): # self.lca.lcia_calculation() self.results[iteration, int(row), col] = self.lca.scores[(m, row)] - logger.info( + log.info( f"Monte Carlo LCA: finished {iterations} iterations for {len(self.func_units)} reference flows and " f"{len(self.methods)} methods in {np.round(time() - start, 2)} seconds." ) @@ -325,10 +331,10 @@ def get_results_by(self, act_key=None, method=None): if act_key: act_index = self.activity_index.get(act_key) - logger.info(f"Activity key provided: {act_key} {act_index}") + log.info(f"Activity key provided: {act_key} {act_index}") if method: method_index = self.method_index.get(method) - logger.info(f"Method provided: {method} {method_index}") + log.info(f"Method provided: {method} {method_index}") if not act_key and not method: return self.results @@ -387,7 +393,7 @@ def get_labels( def perform_MonteCarlo_LCA(project="default", cs_name=None, iterations=10): """Performs Monte Carlo LCA based on a calculation setup and returns the Monte Carlo LCA object.""" - logger.info(f"-- Monte Carlo LCA --\n Project: {project} CS: {cs_name}") + log.info(f"-- Monte Carlo LCA --\n Project: {project} CS: {cs_name}") bd.projects.set_current(project, update=False) # perform Monte Carlo simulation diff --git a/activity_browser/bwutils/multilca.py b/activity_browser/bwutils/multilca.py index 7096de158..9d709443b 100644 --- a/activity_browser/bwutils/multilca.py +++ b/activity_browser/bwutils/multilca.py @@ -1,22 +1,21 @@ from collections import OrderedDict from copy import deepcopy from typing import Iterable, Optional, Union -from loguru import logger +from logging import getLogger -import bw2data as bd import bw2calc as bc import numpy as np import pandas as pd +from qtpy.QtWidgets import QApplication, QMessageBox +from activity_browser.mod import bw2data as bd from activity_browser.mod.bw2analyzer import ABContributionAnalysis from .commontasks import wrap_text from .errors import ReferenceFlowValueError -from .metadata import MetaDataStore - -metadata = MetaDataStore() - +from .metadata import AB_metadata +log = getLogger(__name__) ca = ABContributionAnalysis() @@ -111,8 +110,6 @@ class MLCA(object): """ def __init__(self, cs_name: str, lca_class: bc.LCA = bc.LCA): - from qtpy.QtWidgets import QApplication, QMessageBox - try: cs = bd.calculation_setups[cs_name] except KeyError: @@ -349,16 +346,14 @@ class Contributions(object): DEFAULT_EF_AGGREGATES = ["none"] + DEFAULT_EF_FIELDS def __init__(self, mlca): - from activity_browser.app import metadata - if not isinstance(mlca, MLCA): raise ValueError("Must pass an MLCA object. Passed:", type(mlca)) self.mlca = mlca # Set default metadata keys (those not in the dataframe will be eliminated) - self.act_fields = [fn for fn in self.DEFAULT_ACT_FIELDS if fn in metadata.dataframe.columns] - self.ef_fields = [fn for fn in self.DEFAULT_EF_FIELDS if fn in metadata.dataframe.columns] + self.act_fields = [fn for fn in self.DEFAULT_ACT_FIELDS if fn in AB_metadata.dataframe.columns] + self.ef_fields = [fn for fn in self.DEFAULT_EF_FIELDS if fn in AB_metadata.dataframe.columns] # Specific datastructures for retrieving relevant MLCA data # inventory: inventory, reverse index, metadata keys, metadata fields @@ -508,10 +503,10 @@ def get_labels( translated_keys.append(k) elif isinstance(k, str): translated_keys.append(k) - elif k in metadata.dataframe.index: + elif k in AB_metadata.dataframe.index: translated_keys.append( separator.join( - [str(l) for l in list(metadata.get_metadata(k, fields))] + [str(l) for l in list(AB_metadata.get_metadata(k, fields))] ) ) else: @@ -558,11 +553,11 @@ def join_df_with_metadata( df.index.names = ["database", "code"] # get metadata for rows - keys = [k for k in df.index if k in metadata.dataframe.index] - meta = metadata.get_metadata(keys, x_fields).astype(object) + keys = [k for k in df.index if k in AB_metadata.dataframe.index] + metadata = AB_metadata.get_metadata(keys, x_fields).astype(object) # join data with metadata - joined = meta.join(df, how="outer") + joined = metadata.join(df, how="outer") if special_keys: # replace index keys with labels @@ -570,7 +565,7 @@ def join_df_with_metadata( complete_index = special_keys + keys joined = joined.reindex(complete_index, axis="index", fill_value=0.0) except: - logger.error( + log.error( "Could not put 'Total', 'Rest (+)' and 'Rest (-)' on positions 0, 1 and 2 in the dataframe." ) joined.index = cls.get_labels(joined.index, fields=x_fields) @@ -656,7 +651,7 @@ def _build_inventory( data.columns = Contributions.get_labels(columns, max_length=30) data = pd.merge( - metadata.dataframe[fields], data, right_index=True, left_on="id", how="right" + AB_metadata.dataframe[fields], data, right_index=True, left_on="id", how="right" ) data.reset_index(inplace=True, drop=True) @@ -771,9 +766,9 @@ def aggregate_by_parameters( df = pd.DataFrame(contributions).T columns = list(range(contributions.shape[0])) df.index = rev_index.values() - meta = metadata.dataframe.loc[metadata.dataframe["id"].isin(keys), fields + ["id"]] + metadata = AB_metadata.dataframe.loc[AB_metadata.dataframe["id"].isin(keys), fields + ["id"]] - joined = meta.merge(df, left_on="id", right_index=True, how="left") + joined = metadata.merge(df, left_on="id", right_index=True, how="left") joined.reset_index(inplace=True, drop=True) grouped = joined.groupby(parameters, observed=False) aggregated = grouped[columns].sum() @@ -806,7 +801,7 @@ def _correct_method_index(self, mthd_indx: list) -> dict: conv_dict[mthd] = v return conv_dict - def _contribution_index_cols(self, **kwargs) -> tuple[dict, Optional[Iterable]]: + def _contribution_index_cols(self, **kwargs) -> (dict, Optional[Iterable]): if kwargs.get("method") is not None: return self.mlca.fu_index, self.act_fields return self._correct_method_index(self.mlca.methods), None diff --git a/activity_browser/bwutils/searchengine/__init__.py b/activity_browser/bwutils/searchengine/__init__.py deleted file mode 100644 index a3ed1d8e1..000000000 --- a/activity_browser/bwutils/searchengine/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .base import SearchEngine -from .metadata_search import MetaDataSearchEngine diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py deleted file mode 100644 index 0145e428e..000000000 --- a/activity_browser/bwutils/searchengine/base.py +++ /dev/null @@ -1,779 +0,0 @@ -import itertools -import functools -import re -from collections import Counter, OrderedDict, defaultdict -from typing import Iterable, Optional -from time import time - -from loguru import logger - -import pandas as pd -import numpy as np - - -class SearchEngine: - """ - A Search Engine class, takes a dataframe and makes it searchable. - - A search requires a string, and will return a list of unique identifiers in the dataframe. - There are three options for search: - SearchEngine.literal_search(): searches for exact matches of the search query - SearchEngine.fuzzy_search(): searches for approximate matches of search query, sorted by relevance - SearchEngine.search(): combines both of the above, literal matches are returned first, next all fuzzy results, - but subsets sorted by relevance. - It is recommended to always use searchEngine.search(), but the other options are there. - - Initialization takes: - df: Dataframe that needs to be searchable. - identifier_name: values in this column will be returned as search results, all values in this column need to be unique. - searchable_columns: these columns need to be searchable, if none are given, all columns will be made searchable. - - Updating data is possible as well: - add_identifier(): adds this identifier to the searchable data - remove_identifier(): removes this identifier from the searchable data - change_identifier(): changes this identifier (wrapper for remove_identifier and add_identifier) - - """ - - def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: list = []): - t = time() - logger.debug(f"SearchEngine initializing for {len(df)} items") - - # compile regex patterns for cleaning - self.SUB_END_PATTERN = re.compile(r"[,.\"'`)\[\]}\\/\-−_:;+…]+(?=\s|$)") # remove these from end of word - self.SUB_START_PATTERN = re.compile(r"(?:^|\s)[,.\"'`(\[{\\/\-−_:;+]+") # remove these from start of word - self.ONE_SPACE_PATTERN = re.compile(r"\s+") # remove these multiple whitespaces - - self.q = 2 # character length of q grams - self.base_weight = 10 # base weighting for sorting results - - if identifier_name not in df.columns: # make sure identifier col exist - raise NameError(f"Identifier column {identifier_name} not found in dataframe. Use an existing column name.") - if df[identifier_name].nunique() != df.shape[0]: # make sure identifiers are all unique - raise KeyError( - f"Identifier column {identifier_name} must only contain unique values. Found {df[identifier_name].nunique()} unique values for length {df.shape[0]}") - - self.identifier_name = identifier_name - - # ensure columns given actually exist - # always ensure "identifier" is present - if searchable_columns == []: - # if no list is given, assume all columns are searchable - self.columns = list(df.columns) - else: - # create subset of columns to be searchable, discard rest - self.columns = [col for col in searchable_columns if col in df.columns] - if self.identifier_name not in self.columns: # keep identifier col - self.columns.append(self.identifier_name) - df = df[self.columns] - # set the identifier column as index - df = df.set_index(self.identifier_name, drop=False) - - # convert all data to str - df = df.astype(str) - - # find the self.identifier_name column index and store as int - self.identifier_column = self.columns.index(self.identifier_name) - - # store all searchable column indices except the identifier - self.searchable_columns = [i for i in range(len(self.columns)) if i != self.identifier_column] - - # initialize search index dicts and update df - self.identifier_to_word = {} - self.word_to_identifier = {} - self.word_to_q_grams = {} - self.q_gram_to_word = {} - self.df = pd.DataFrame() - - self.update_index(df) - - logger.debug(f"SearchEngine Initialized in {time() - t:.2f} seconds") - - # +++ Utility functions - - def update_index(self, update_df: pd.DataFrame) -> None: - """Update search index dicts and the df.""" - - def update_dict(update_me: dict, new: dict) -> dict: - """Update a dict of counters with new dict of counters.""" - # set to empty set if we know update_me is empty, otherwise, find set intersection - update_keys = set() if len(update_me) == 0 else new.keys() & update_me.keys() - if len(update_keys) == 0: - new_data = new - else: - for update_key in update_keys: - update_me[update_key].update(new[update_key]) - new_data = {key: value for key, value in new.items() if key not in update_keys} - # finally add any completely new data - # update_me.update(new_data) - update_me = update_me | new_data - return update_me - - if len(update_df) == 0: - return - - t = time() - size_old = len(self.df) - # identifier to word and df - i2w, update_df = self.words_in_df(update_df) - self.identifier_to_word = update_dict(self.identifier_to_word, i2w) - self.df = pd.concat([self.df, update_df]) - # word to identifier - w2i = self.reverse_dict_many_to_one(i2w) - self.word_to_identifier = update_dict(self.word_to_identifier, w2i) - # word to q-gram - w2q = self.list_to_q_grams(w2i.keys()) - self.word_to_q_grams = update_dict(self.word_to_q_grams, w2q) - # q-gram to word - q2w = self.reverse_dict_many_to_one(w2q) - self.q_gram_to_word = update_dict(self.q_gram_to_word, q2w) - size_new = len(self.df) - size_dif = size_new - size_old - logger.debug(f"Search index updated in {time() - t:.2f} seconds.") - - def clean_text(self, text: str): - """Clean a string so it doesn't contain weird characters or multiple spaces etc.""" - text = text.lower() - text = self.SUB_END_PATTERN.sub("", text) - text = self.SUB_START_PATTERN.sub(" ", text) - - text = self.ONE_SPACE_PATTERN.sub(" ", text).strip() - return text - - def text_to_positional_q_gram(self, text: str) -> list: - """Return a positional list of q-grams for the given string. - - q-grams are n-grams on character level. - q-grams at q=2 of "word" would be "wo", "or" and "rd" - https://en.wikipedia.org/wiki/N-gram - - Note: these are technically _positional_ q-grams, but we don't use their positions currently. - """ - q = self.q - n = len(text) - # just return a single-item list if the text is equal or shorter than q - # else, generate q-grams - if n <= q: - return [text] - return list(text[i:i + q] for i in range(n - q + 1)) - - def df_clean(self, df): - """Clean the text in query_col. - - apply multi-processing when the computer is able and its relevant - """ - df["query_col"] = df["query_col"].apply(self.clean_text) - return df - - def words_in_df(self, df: pd.DataFrame = None) -> tuple[dict, pd.DataFrame]: - """Return a dict of {identifier: word} for df.""" - - df = df if df is not None else self.df.copy() - df = df.fillna("") # avoid nan - # assemble query_col - df["query_col"] = df.iloc[:, self.searchable_columns].astype(str).agg(" | ".join, axis=1) - # clean all text at once using vectorized operations - df["query_col"] = self.df_clean(df.loc[:, ["query_col"]]) - # build the identifier_word_dict dictionary - filter out empty strings - identifier_word_dict = df["query_col"].apply( - lambda text: Counter(word for word in text.split(" ") if word) - ).to_dict() - return identifier_word_dict, df - - def reverse_dict_many_to_one(self, dictionary: dict) -> dict: - """Reverse a dictionary of Counter objects.""" - reverse = defaultdict(Counter) - for identifier, counter_object in dictionary.items(): - if not isinstance(counter_object, Counter): - logger.warning(f"Skipping non-Counter object for {identifier}: {type(counter_object)}") - continue - for countable, count in counter_object.items(): - if countable: # skip empty strings - reverse[countable][identifier] += count - return dict(reverse) - - def list_to_q_grams(self, word_list: Iterable) -> dict: - """Convert a list of unique words to a dict with Counter objects. - - Number will be the occurrences of that q-gram in that word. - - return = { - "word": Counter( - "wo": 1 - "or": 1 - "rd": 1 - ), - ... - } - """ - text_to_q_gram = self.text_to_positional_q_gram - return { - word: Counter(text_to_q_gram(word)) - for word in word_list - } - - def word_in_index(self, word: str) -> bool: - """Convenience function to check if a single word is in the search index.""" - if " " in word: - raise Exception( - f"Given word '{word}' must not contain spaces.") - return word in self.word_to_identifier.keys() - - # +++ Changes to searchable data - - def add_identifier(self, data: pd.DataFrame) -> None: - """Add this data to the search index. - - identifier column is REQUIRED to be present - ALL data in the given dataframe will be added, if columns should not be added, they should be removed before - calling this function - """ - - # ensure we have identifier column - if self.identifier_name not in data.columns: - raise Exception( - f"Identifier column '{self.identifier_name}' not in new data, impossible to add data without identifier") - - # make sure we the new identifiers do not yet exist - existing_ids = set(self.df.index.to_list()) - for identifier in data[self.identifier_name]: - if identifier in existing_ids: - raise Exception( - f"Identifier '{identifier}' is already in use, use a different identifier or use the change_identifier function.") - - # make sure all new identifiers given are unique - if data[self.identifier_name].nunique() != data.shape[0]: - raise KeyError( - f"Identifier column {self.identifier_name} must only contain unique values. Found {data[self.identifier_name].nunique()} unique values for length {data.shape[0]}") - - df_cols = self.columns - # add cols to new data that are missing - for col in df_cols: - if col not in data.columns: - data.loc[:, col] = [""] * len(data) - # re-order cols, first existing, then new - df_col_set = set(df_cols) - new_cols = [col for col in data.columns if col not in self.columns if col not in df_col_set] - data_cols = df_cols + new_cols - data = data[data_cols] # re-order new data to be in correct order - - # add cols from new data to correct places - self.columns.extend(new_cols) - self.searchable_columns.extend([i for i, col in enumerate(data_cols) if col in new_cols]) - - # convert df - data = data.set_index(self.identifier_name, drop=False) - data = data.astype(object).fillna("") - data = data.astype(str) - - # update the search index data - self.update_index(data) - - def remove_identifier(self, identifier, logging=True) -> None: - """Remove this identifier from self.df and the search index. - """ - if logging: - t = time() - - # make sure the identifier exists - if identifier not in self.df.index.to_list(): - logger.warning( - f"Identifier '{identifier}' does not exist in the search data, cannot remove identifier that do not exist." - ) - return - - self.df = self.df.drop(identifier) - - # find words that may need to be removed - words = self.identifier_to_word[identifier] - for word in words: - if len(self.word_to_identifier[word]) == 1: - # this word is only found in this identifier, - # remove the word and check for q grams - del self.word_to_identifier[word] - - q_grams = self.word_to_q_grams[word] - for q_gram in q_grams: - if len(self.q_gram_to_word[q_gram]) == 1: - # this q_gram is only used in this word, - # remove it - del self.q_gram_to_word[q_gram] - elif len(self.q_gram_to_word[q_gram]) > 1: - # this q_gram is used in multiple words, only remove the word from the q_gram - del self.q_gram_to_word[q_gram][word] - - del self.word_to_q_grams[word] - else: - # this word is found in multiple identifiers - # word_to_q_gram and q_gram_to_word do not need to be changed, the word still exists - # remove the identifier the word in word_to_identifier - del self.word_to_identifier[word][identifier] - # finally, remove the identifier - del self.identifier_to_word[identifier] - - if logging: - logger.debug(f"Search index updated in {time() - t:.2f} seconds " - f"for 1 removed item ({len(self.df)}.") - - def change_identifier(self, identifier, data: pd.DataFrame) -> None: - """Change this identifier. - - identifier must be an identifier that is in use - data must be a dataframe of 1 row with all change data - data is overwritten with the new data in 'data', columns not given remain unchanged - """ - - # make sure only 1 change item is given - if len(data) > 1 or len(data) < 1: - raise Exception( - f"change data must be for exactly 1 identifier, but {len(data)} items were given.") - # make sure correct use of identifier - if identifier not in self.df.index.to_list(): - raise Exception( - f"Identifier '{identifier}' does not exist in the search data, use an existing identifier or use the add_identifier function.") - if self.identifier_name in data.columns and data[self.identifier_name].to_list() != [identifier]: - raise Exception( - "Identifier field cannot be changed, first remove item and then add new identifier") - if "query_col" in data.keys(): - logger.debug( - f"Field 'query_col' is a protected field for search engine and will be ignored for changing {identifier}") - - - # overwrite new data where relevant - update_data = self.df.loc[[identifier], self.columns] - data = data.reset_index(drop=True) - for col in data.columns: - value = data.loc[0, col] - update_data[col] = [value] - - # remove the entry - self.remove_identifier(identifier, logging=False) - # add entry with updated data - self.add_identifier(update_data) - - # +++ Search - - def filter_dataframe(self, df: pd.DataFrame, pattern: str, search_columns: Optional[list] = None) -> pd.Series: - """Filter the search columns of a dataframe on a pattern. - - Returns a mask (true/false) pd.Series with matching items.""" - - search_columns = search_columns if search_columns else self.columns - mask = functools.reduce( - np.logical_or, - [ - df[col].apply(lambda x: pattern in x.lower()) - for col in search_columns - ], - ) - return mask - - def literal_search(self, text, df: Optional[pd.DataFrame] = None) -> list: - """Do literal search of the text in all original columns that were given.""" - - if df is None: - df = self.df.copy() - - identifiers = self.filter_dataframe(df, text) - df = df.loc[identifiers] - identifiers = df.index.to_list() - return identifiers - - def osa_distance(self, word1: str, word2: str, cutoff: int = 0, cutoff_return: int = 1000) -> int: - """Calculate the Optimal String Alignment (OSA) edit distance between two strings, return edit distance. - - Has additional cutoff variable, if cutoff is higher than 0 and if the words have - a larger edit distance, return a large number (note: cutoff <= edit_dist, not cutoff < edit_dist) - - OSA is a restricted form of the Damerau–Levenshtein distance. - https://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance#Optimal_string_alignment_distance - - The edit distance is how many operations (insert, delete, substitute or transpose a character) need to happen to convert one string to another. - insert and delete are obvious operations, but substitute and transpose are explained: - substitute: replace one character with another: e.g. word1='cat' word2='cab', 't'->'b' substitution is 1 operation - transpose: swap the places of two adjacent characters with each other: e.g. word1='coal' word2='cola' 'al' -> 'la' transposition is 1 operation - - The minimum amount of edit operations (OSA edit distance) is returned. - """ - if word1 == word2: - # if the strings are the same, immediately return 0 - return 0 - - len1, len2 = len(word1), len(word2) - - if 0 < cutoff <= abs(len1 - len2): - # if the length difference between 2 words is over the cutoff, - # just return instead of calculating the edit distance - return cutoff_return - - if len1 == 0 or len2 == 0: - # in case (at least) one of the strings is empty, - # return the length of the longest string - return max(len1, len2) - - if len1 < len2 and cutoff > 0: - # make sure word1 is always the longest (required for early stopping with cutoff) - word1, word2 = word2, word1 - len1, len2 = len2, len1 - - # Initialize matrix - distance = [[0] * len2 for _ in range(len1)] - - # calculate shortest edit distance - for i in range(len1): - for j in range(len2): - cost = 0 if word1[i] == word2[j] else 1 - - # Compute distances for insertion, deletion and substitution - insertion = distance[i][j - 1] + 1 if j > 0 else i + 1 - deletion = distance[i - 1][j] + 1 if i > 0 else j + 1 - substitution = distance[i - 1][j - 1] + cost if i > 0 and j > 0 else max(i, j) + cost - - distance[i][j] = min(deletion, insertion, substitution) - - # Compute transposition when relevant - if i > 0 and j > 0 and word1[i] == word2[j - 1] and word1[i - 1] == word2[j]: - transposition = distance[i - 2][j - 2] + 1 if i > 1 and j > 1 else max(i, j) - 1 - distance[i][j] = min(distance[i][j], transposition) - - # stop early if we surpass cutoff - if 0 < cutoff <= min(distance[i]): - return cutoff_return - return distance[i][j] - - def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: - """Find which of the given q_grams exist in self.q_gram_to_word, - return a sorted dataframe of best matching words. - """ - n_q_grams = len(q_grams) - - matches = {} - - # find words that match our q-grams - for q_gram in q_grams: - if words := self.q_gram_to_word.get(q_gram, False): - # q_gram exists in our search index - for word in words: - matches[word] = matches.get(word, 0) + words[word] - - # if we find no results, return an empty dataframe - if len(matches) == 0: - return pd.DataFrame({"word": [], "matches": []}) - - # otherwise, create a dataframe and - # reduce search results to most relevant results - matches = {"word": matches.keys(), "matches": matches.values()} - matches = pd.DataFrame(matches) - max_q = max(matches["matches"]) # this has the most matching q-grams - - # determine how many results we want to keep based on how good our results are - min_q = min(max(max_q * 0.32, # have at least a third of q-grams of best match or... - max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? - 1)), # okay just do 1 q-gram if there are no more in the word - max_q) # never have min_q be over max_q - - matches = matches[matches["matches"] >= min_q] - matches = matches.sort_values(by="matches", ascending=False) - matches = matches.reset_index(drop=True) - - return matches.iloc[:min(len(matches), 2500), :] # return at most this many results - - def spell_check(self, text: str, skip_len=1) -> OrderedDict: - """Create an OrderedDict of each word in the text (space separated) - with as values possible alternatives. - - Alternatives are first found with q-grams, then refined with string edit distance - - We rank alternative words based on 1) edit distance 2) how often a word is used in an entry - If too many results are found, we only keep edit distance 1, - if we want more results, we keep with longer edit distance up to `never_accept_this` - - word_results = OrderedDict( - "word": [work] - ) - - NOTE: only ALTERNATIVES are ever returned, this function returns empty list for item BOTH when - 1) the exact word is in the data - 2) when there are no suitable alternatives - """ - count_occurence = lambda x: sum(self.word_to_identifier[x].values()) # count occurences of a word - - word_results = OrderedDict() - - matches_min = 3 # ideally we have at least this many alternatives - matches_max = 10 # ideally don't much more than this many matches - always_accept_this = 1 # values of this edit distance or lower always accepted - never_accept_this = 4 # values this edit distance or over always rejected - - # make list of unique words - text = self.clean_text(text) - words = OrderedDict() - for word in text.split(" "): - if len(word) != 0: - words[word] = False - words = words.keys() - - for word in words: - if len(word) <= skip_len: # dont look for alternatives for text this short - word_results[word] = [] - continue - - # reduce acceptable edit distance with short words - dont_accept = int(round(max(1, min((len(word) * 0.66), never_accept_this)), 0)) - - # first, find possible matches quickly - q_grams = self.text_to_positional_q_gram(word) - possible_matches = self.find_q_gram_matches(set(q_grams)) - - first_matches = Counter() - other_matches = {} - - # now, refine with edit distance - for row in possible_matches.itertuples(): - - edit_distance = self.osa_distance(word, row[1], cutoff=dont_accept) - - if edit_distance == 0: - continue # we are looking for alternatives only, not the exact word - elif edit_distance <= always_accept_this: - first_matches[row[1]] = count_occurence(row[1]) - elif edit_distance < dont_accept: - if not other_matches.get(edit_distance): - other_matches[edit_distance] = Counter() - other_matches[edit_distance][row[1]] = count_occurence(row[1]) - else: - continue - - # add matches in correct order: - matches = [match for match, _ in first_matches.most_common()] - # if we have fewer matches than goal, add more 'less good' matches - if len(matches) < matches_min: - for i in range(always_accept_this + 1, dont_accept): - # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives - if new := other_matches.get(i): - prev_num = 10e100 - for match, num in new.most_common(): - if num == prev_num: - matches.append(match) - elif num != prev_num and len(matches) <= matches_max: - matches.append(match) - else: - break - prev_num = num - - word_results[word] = matches - return word_results - - def build_queries(self, query_text) -> list: - """Make all possible subsets of words in the query, including alternative words.""" - query_text = self.spell_check(query_text) - - # find all combinations of the query words as given - queries = list(query_text.keys()) - subsets = list(itertools.chain.from_iterable( - (itertools.combinations( - queries, r) for r in range(1, len(queries) + 1)))) - all_queries = [] - - for combination in subsets: - # add the 'default' option - all_queries.append(combination) - # now add all options with all alternatives - for i, word in enumerate(combination): - for alternative in query_text.get(word, []): - alternative_combination = list(combination) - alternative_combination[i] = alternative - all_queries.append(alternative_combination) - - return all_queries - - def weigh_identifiers(self, identifiers: Counter, weight: int, weighted_ids: Counter) -> Counter: - """Add weights to identifier counter for these identifiers times how often it occurs in identifier.""" - for identifier, occurrences in identifiers.items(): - weighted_ids[identifier] += (weight * occurrences) - return weighted_ids - - def search_size_1(self, queries: list, original_words: set, orig_word_weight=5, exact_word_weight=1) -> dict: - """Return a dict of {query_word: Counter(identifier)}. - - queries: is a list of len 1 tuple/lists of words that are a searched word or a 'spell checked' similar word - original words: a list of words actually searched for (not including spellchecked) - - orig_word_weight: additional weight to add to original words - exact_word_weight: additional weight to add to exact word matches (as opposed to be 'in' str) - - First, we find all matching words, creating a dict of words in 'queries' as keys and words matching that query word as list of values - Next, we convert this to identifiers and add weights: - Weight will be increased if matching 'orig_word_weight' or 'exact_word_weight' - """ - matches = {} - # add each word in search index if query_word in word - for word in self.word_to_identifier.keys(): - for query in queries: - # query is list/tuple of len 1 - query_word = query[0] # only use the word - if query_word in word: - words = matches.get(query_word, []) - words.extend([word]) - matches[query_word] = words - - # now convert matched words to matched identifiers - matched_identifiers = {} - for word, matching_words in matches.items(): - for matched_word in matching_words: - weight = self.base_weight - id_counter = matched_identifiers.get(word, Counter()) - - # add the word n times, where n is the weight, original search word is weighted higher than alternatives - if matched_word in original_words: - weight += orig_word_weight # increase weight for original word - if matched_word == word: - weight += exact_word_weight # increase weight for exact matching word - - id_counter = self.weigh_identifiers(self.word_to_identifier[matched_word], weight, id_counter) - matched_identifiers[word] = id_counter - - return matched_identifiers - - def fuzzy_search(self, text: str, return_counter: bool = False) -> list: - """Search the dataframe, finding approximate matches and return a list of identifiers, - ranked by how well each identifier matches the search text. - - 1. First, identifiers matching single words (and spell-checked alternatives) are found and weighted. - 2. If the search term consisted of multiple words, combinations of those words are checked next. - 2.1 Increasing in size (first two words, then three etc.), we look for identifiers that contain that set of - words, these are also weighted, based on the sum of all one-word weights (from first step) and the length - of the sequence. - 2.2 Next, we also look specifically for combinations occurring next to each other. And add more weight like - the step above (2.1). - We multiply the weighting of step 2 by the sequence length, based on the assumption that finding more search - words will be a more relevant result than just finding a single word, and again if they are in the - correct order. - - Finally, all found identifiers are sorted on their weight and returned. - """ - text = text.strip() - - queries = self.build_queries(text) - - # make list of unique original words - orig_words = OrderedDict() - for word in text.split(" "): - orig_words[word] = False - orig_words = orig_words.keys() - orig_words = {self.clean_text(word) for word in orig_words} - - # order the queries by the amount of words they contain - # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space - queries_by_size = OrderedDict() - longest_query = max([len(q) for q in queries]) - for query_len in range(1, longest_query + 1): - queries_by_size[query_len] = [q for q in queries if len(q) == query_len] - - # first handle queries of length 1 - query_to_identifier = self.search_size_1(queries_by_size[1], orig_words) - - # get all results into a df, we rank further later - all_identifiers = set() - for id_list in [id_list for id_list in query_to_identifier.values()]: - all_identifiers.update(id_list) - search_df = self.df.loc[list(all_identifiers)] - - # now, we search for combinations of query words and get only those identifiers - # we then reduce de search_df further for only those matching identifiers - # we then search the permutations of that set of words - for q_len, query_set in queries_by_size.items(): - if q_len == 1: - # we already did these above - continue - for query in query_set: - # get the intersection of all identifiers - # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query - # this ensures we only ever search data where ALL items occur to substantially reduce search-space - # finally, make this a Counter (with each item=1) so we can properly weigh things later - query_id_sets = [set(query_to_identifier.get(q_word)) for q_word in query if - query_to_identifier.get(q_word, False)] - if len(query_id_sets) == 0: - continue - query_identifier_set = set.intersection(*query_id_sets) - if len(query_identifier_set) == 0: - # there is no match for this combination of query words, skip - break - - # now we convert the query identifiers to a Counter of 'occurrence', - # where we weigh queries with only original words higher - query_identifiers = Counter() - for identifier in query_identifier_set: - weight = 0 - for query_word in query: - # if the query_word and identifier combination exist get score, otherwise 0 - weight += query_to_identifier.get(query_word, {}).get(identifier, 0) - - query_identifiers[identifier] = weight - - # we now add these identifiers to a counter for this query name, - query_name = " ".join(query) - - weight = self.base_weight * q_len - query_to_identifier[query_name] = self.weigh_identifiers(query_identifiers, weight, Counter()) - - # now search for all permutations of this query combined with a space - query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] - for query_perm in itertools.permutations(query): - mask = self.filter_dataframe(query_df, " ".join(query_perm), search_columns=["query_col"]) - new_df = query_df.loc[mask].reset_index(drop=True) - if len(new_df) == 0: - # there is no match for this permutation of words, skip - continue - new_id_list = new_df[self.identifier_name] - - new_ids = Counter() - for new_id in new_id_list: - new_ids[new_id] = query_identifiers[new_id] - - # we weigh a combination of words that is next also to each other even higher than just the words separately - query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, - query_to_identifier[query_name]) - # now finally, move to one object sorted list by highest score - all_identifiers = Counter() - for identifiers in query_to_identifier.values(): - all_identifiers += identifiers - - if return_counter: - return all_identifiers - # now sort on highest weights and make list type - sorted_identifiers = [identifier for identifier, _ in all_identifiers.most_common()] - return sorted_identifiers - - def search(self, text) -> list: - """Search the dataframe on this text, return a sorted list of identifiers.""" - t = time() - text = text.strip() - - if len(text) == 0: - logger.debug(f"Empty search, returned all items") - return self.df.index.to_list() - - fuzzy_identifiers = self.fuzzy_search(text) - if len(fuzzy_identifiers) == 0: - logger.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - return [] - - # take the fuzzy search sub-set of data and search it literally - df = self.df.loc[fuzzy_identifiers].copy() - - literal_identifiers = self.literal_search(text, df) - if len(literal_identifiers) == 0: - logger.debug( - f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - return fuzzy_identifiers - - # append any fuzzy identifiers that were not found in the literal search - literal_id_set = set(literal_identifiers) - remaining_fuzzy_identifiers = [ - _id for _id in fuzzy_identifiers if _id not in literal_id_set] - identifiers = literal_identifiers + remaining_fuzzy_identifiers - - logger.debug( - f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - return identifiers diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py deleted file mode 100644 index 1814a3e8a..000000000 --- a/activity_browser/bwutils/searchengine/metadata_search.py +++ /dev/null @@ -1,447 +0,0 @@ -from itertools import permutations -from collections import Counter, OrderedDict -from logging import getLogger -from time import time -from typing import Optional -import pandas as pd - -from activity_browser.bwutils.searchengine import SearchEngine - - -log = getLogger(__name__) - - -class MetaDataSearchEngine(SearchEngine): - - # caching for faster operation - def database_id_manager(self, database): - if not hasattr(self, "all_database_ids"): - self.all_database_ids = {} - - if database_ids := self.all_database_ids.get(database): - self.database_ids = database_ids - self.current_database = database - elif database is not None: - self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) - self.all_database_ids[database] = self.database_ids - self.current_database = database - else: - self.database_ids = None - self.current_database = "_@@NO_DB_" - return self.database_ids - - def reset_database_id_manager(self): - if hasattr(self, "all_database_ids"): - del self.all_database_ids - if hasattr(self, "database_ids"): - del self.database_ids - - def database_word_manager(self, database): - if not hasattr(self, "all_database_words"): - self.all_database_words = {} - - if database_words := self.all_database_words.get(database): - self.database_words = database_words - elif database is not None: - ids = self.database_id_manager(database) - self.database_words = self.reverse_dict_many_to_one({_id: self.identifier_to_word[_id] for _id in ids}) - self.all_database_words[database] = self.database_words - else: - self.database_words = None - return self.database_words - - def reset_database_word_manager(self, database): - if hasattr(self, "all_database_words") and self.all_database_words.get(database): - del self.all_database_words[database] - if hasattr(self, "database_words"): - del self.database_words - - def database_search_cache(self, database, query, result = None): - if not hasattr(self, "search_cache"): - self.search_cache = {} - - if result: - if self.search_cache.get(database): - self.search_cache[database][query] = result - else: - self.search_cache[database] = {query: result} - return - if db_cache := self.search_cache.get(database): - if cached_result := db_cache.get(query): - return cached_result - return - - def reset_search_cache(self, database): - if hasattr(self, "search_cache") and self.search_cache.get(database): - del self.search_cache[database] - - def reset_all_caches(self, databases): - self.reset_database_id_manager() - for database in databases: - self.reset_database_word_manager(database) - self.reset_search_cache(database) - - def add_identifier(self, data: pd.DataFrame) -> None: - super().add_identifier(data) - self.reset_all_caches(data["database"].unique()) - - def remove_identifiers(self, identifiers, logging=True) -> None: - t = time() - - identifiers = set(identifiers) - current_identifiers = set(self.df.index.to_list()) - identifiers = identifiers | current_identifiers # only remove identifiers currently in the data - databases = self.df.loc[identifiers, ["databases"]].unique() # extract databases for cache cleaning - if len(identifiers) == 0: - return - - for identifier in identifiers: - super().remove_identifier(identifier, logging=False) - - if logging: - log.debug(f"Search index updated in {time() - t:.2f} seconds " - f"for {len(identifiers)} removed items ({len(self.df)} items ({self.size_of_index()}) currently).") - self.reset_all_caches(databases) - - def change_identifier(self, identifier, data: pd.DataFrame) -> None: - super().change_identifier(identifier, data) - self.reset_all_caches(data["database"].unique()) - - def auto_complete(self, word: str, context: Optional[set] = set(), database: Optional[str] = None) -> list: - """Based on spellchecker, make more useful for autocompletions - """ - def word_to_identifier_to_word(check_word): - if len(context) == 0: - return 1 - multiplier = 1 - for identifier in self.word_to_identifier[check_word]: - for context_word in context: - for spell_checked_context_word in spell_checked_context[context_word]: - if spell_checked_context_word in self.identifier_to_word[identifier]: - multiplier += 1 - if context_word not in self.word_to_identifier.keys(): - continue - if context_word in self.identifier_to_word[identifier]: - multiplier += 4 - return multiplier - - # count occurrences of a word, count double so word_to_identifier_to_word will never multiply by 1 - count_occurrence = lambda x: sum(self.word_to_identifier[x].values()) * 2 - - if len(word) <= 1: - return [] - - self.database_id_manager(database) - - if len(context) > 0: - spell_checked_context = {} - for context_word in context: - spell_checked_context[context_word] = self.spell_check(context_word).get(context_word, [])[:5] - - matches_min = 2 # ideally we have at least this many alternatives - matches_max = 4 # ideally don't much more than this many matches - never_accept_this = 4 # values this edit distance or over always rejected - # or max 2/3 of len(word) if less than never_accept_this - never_accept_this = int(round(max(1, min((len(word) * 0.66), never_accept_this)), 0)) - - # first, find possible matches quickly - q_grams = self.text_to_positional_q_gram(word) - possible_matches = self.find_q_gram_matches(set(q_grams), return_all=True) - - first_matches = Counter() - other_matches = {} - probably_keys = Counter() # if we suspect it's a key hash, dump it at the end of the list - - # now, refine with edit distance - for row in possible_matches.itertuples(): - if word == row[1]: - continue - # find edit distance of same size strings - edit_distance = self.osa_distance(word, row[1][:len(word)], cutoff=never_accept_this) - if len(row[1]) == 32 and edit_distance <= 1: - probably_keys[row[1]] = 100 - edit_distance # keys need to be sorted on edit distance, not on occurence - elif edit_distance == 0: - first_matches[row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) - elif edit_distance < never_accept_this and len(first_matches) < matches_min: - if not other_matches.get(edit_distance): - other_matches[edit_distance] = Counter() - other_matches[edit_distance][row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) - else: - continue - - # add matches in correct order: - matches = [match for match, _ in first_matches.most_common()] - # if we have fewer matches than goal, add more 'less good' matches - if len(matches) < matches_min: - for i in range(1, never_accept_this): - # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives - if new := other_matches.get(i): - prev_num = 10e100 - for match, num in new.most_common(): - if num == prev_num: - matches.append(match) - elif num != prev_num and len(matches) <= matches_max: - matches.append(match) - else: - break - prev_num = num - - matches = matches + [match for match, _ in probably_keys.most_common()] - return matches - - def find_q_gram_matches(self, q_grams: set, return_all: bool = False) -> pd.DataFrame: - """Overwritten for extra database specific reduction of results. - """ - n_q_grams = len(q_grams) - - matches = {} - - # find words that match our q-grams - for q_gram in q_grams: - if words := self.q_gram_to_word.get(q_gram, False): - # q_gram exists in our search index - for word in words: - if isinstance(self.database_ids, set): - # DATABASE SPECIFIC now filter on whether word is in the database - in_db = False - for _id in self.word_to_identifier[word]: - if _id in self.database_ids: - in_db = True - break - else: - in_db = True - if in_db: - matches[word] = matches.get(word, 0) + words[word] - - # if we find no results, return an empty dataframe - if len(matches) == 0: - return pd.DataFrame({"word": [], "matches": []}) - - # otherwise, create a dataframe and - # reduce search results to most relevant results - matches = {"word": matches.keys(), "matches": matches.values()} - matches = pd.DataFrame(matches) - max_q = max(matches["matches"]) # this has the most matching q-grams - - # determine how many results we want to keep based on how good our results are - if not return_all: - min_q = min(max(max_q * 0.32, # have at least a third of q-grams of best match or... - max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? - 1)), # okay just do 1 q-gram if there are no more in the word - max_q) # never have min_q be over max_q - else: - min_q = 0 - - matches = matches[matches["matches"] >= min_q] - matches = matches.sort_values(by="matches", ascending=False) - matches = matches.reset_index(drop=True) - - return matches.iloc[:min(len(matches), 2500), :] # return at most this many results - - def search_size_1(self, queries: list, original_words: set, orig_word_weight=5, exact_word_weight=1) -> dict: - """Return a dict of {query_word: Counter(identifier)}. - - queries: is a list of len 1 tuple/lists of words that are a searched word or a 'spell checked' similar word - original words: a list of words actually searched for (not including spellchecked) - - orig_word_weight: additional weight to add to original words - exact_word_weight: additional weight to add to exact word matches (as opposed to be 'in' str) - - First, we find all matching words, creating a dict of words in 'queries' as keys and words matching that query word as list of values - Next, we convert this to identifiers and add weights: - Weight will be increased if matching 'orig_word_weight' or 'exact_word_weight' - """ - matches = {} - t2 = time() - # add each word in search index if query_word in word - for word in self.database_words.keys(): - for query in queries: - # query is list/tuple of len 1 - query_word = query[0] # only use the word - if query_word in word: - words = matches.get(query_word, []) - words.extend([word]) - matches[query_word] = words - - # now convert matched words to matched identifiers - matched_identifiers = {} - for word, matching_words in matches.items(): - if result := self.database_search_cache(self.current_database, word): - matched_identifiers[word] = result - continue - id_counter = matched_identifiers.get(word, Counter()) - for matched_word in matching_words: - weight = self.base_weight - - # add the word n times, where n is the weight, original search word is weighted higher than alternatives - if matched_word in original_words: - weight += orig_word_weight # increase weight for original word - if matched_word == word: - weight += exact_word_weight # increase weight for exact matching word - - id_counter = self.weigh_identifiers(self.database_words[matched_word], weight, id_counter) - matched_identifiers[word] = id_counter - self.database_search_cache(self.current_database, word, matched_identifiers[word]) - - return matched_identifiers - - def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter: bool = False, logging: bool = True) -> list: - """Overwritten for extra database specific reduction of results. - """ - t = time() - text = text.strip() - - if len(text) == 0: - log.debug(f"Empty search, returned all items") - return self.df.index.to_list() - - # DATABASE SPECIFIC get the set of ids that is in this database - self.database_id_manager(database) - self.database_word_manager(database) - - queries = self.build_queries(text) - - # make list of unique original words - orig_words = OrderedDict() - for word in text.split(" "): - orig_words[word] = False - orig_words = orig_words.keys() - orig_words = {self.clean_text(word) for word in orig_words} - - # order the queries by the amount of words they contain - # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space - queries_by_size = OrderedDict() - longest_query = max([len(q) for q in queries]) - for query_len in range(1, longest_query + 1): - queries_by_size[query_len] = [q for q in queries if len(q) == query_len] - - # first handle queries of length 1 - query_to_identifier = self.search_size_1(queries_by_size[1], orig_words) - - # DATABASE SPECIFIC ensure all identifiers are in the database - if isinstance(self.database_ids, set): - new_q2i = {} - for word, _ids in query_to_identifier.items(): - keep = set.intersection(set(_ids.keys()), self.database_ids) - new_id_counter = Counter() - for _id in keep: - new_id_counter[_id] = _ids[_id] - if len(new_id_counter) > 0: - new_q2i[word] = new_id_counter - query_to_identifier = new_q2i - - # get all results into a df, we rank further later - all_identifiers = set() - for id_list in [id_list for id_list in query_to_identifier.values()]: - all_identifiers.update(id_list) - search_df = self.df.loc[list(all_identifiers)] - - # now, we search for combinations of query words and get only those identifiers - # we then reduce de search_df further for only those matching identifiers - # we then search the permutations of that set of words - for q_len, query_set in queries_by_size.items(): - if q_len == 1: - # we already did these above - continue - for query in query_set: - # get the intersection of all identifiers - # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query - # this ensures we only ever search data where ALL items occur to substantially reduce search-space - # finally, make this a Counter (with each item=1) so we can properly weigh things later - query_id_sets = [set(query_to_identifier.get(q_word)) for q_word in query if - query_to_identifier.get(q_word, False)] - if len(query_id_sets) == 0: - continue - query_identifier_set = set.intersection(*query_id_sets) - if len(query_identifier_set) == 0: - # there is no match for this combination of query words, skip - break - - # now we convert the query identifiers to a Counter of 'occurrence', - # where we weigh queries with only original words higher - query_identifiers = Counter() - for identifier in query_identifier_set: - weight = 0 - for query_word in query: - # if the query_word and identifier combination exist get score, otherwise 0 - weight += query_to_identifier.get(query_word, {}).get(identifier, 0) - - query_identifiers[identifier] = weight - - # we now add these identifiers to a counter for this query name, - query_name = " ".join(query) - - weight = self.base_weight * q_len - query_to_identifier[query_name] = self.weigh_identifiers(query_identifiers, weight, Counter()) - - # now search for all permutations of this query combined with a space - query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] - for query_perm in permutations(query): - query_perm_str = " ".join(query_perm) - if result := self.database_search_cache(self.current_database, query_perm_str): - new_ids = result - else: - mask = self.filter_dataframe(query_df, query_perm_str, search_columns=["query_col"]) - new_df = query_df.loc[mask].reset_index(drop=True) - if len(new_df) == 0: - # there is no match for this permutation of words, skip - continue - new_id_list = new_df[self.identifier_name] - - new_ids = Counter() - for new_id in new_id_list: - new_ids[new_id] = query_identifiers[new_id] - self.database_search_cache(self.current_database, query_perm_str, new_ids) - # we weigh a combination of words that is next also to each other even higher than just the words separately - query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, - query_to_identifier[query_name]) - # now finally, move to one object sorted list by highest score - all_identifiers = Counter() - for identifiers in query_to_identifier.values(): - all_identifiers += identifiers - - if return_counter: - return_this = all_identifiers - else: - # now sort on highest weights and make list type - return_this = [identifier[0] for identifier in all_identifiers.most_common()] - if logging: - log.debug( - f"Found {len(all_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - return return_this - - def search(self, text, database: Optional[str] = None) -> list: - """Search the dataframe on this text, return a sorted list of identifiers.""" - t = time() - text = text.strip() - - if len(text) == 0: - log.debug(f"Empty search, returned all items") - return self.df.index.to_list() - - # get the set of ids that is in this database - self.database_id_manager(database) - - fuzzy_identifiers = self.fuzzy_search(text, database=database, logging=False) - if len(fuzzy_identifiers) == 0: - log.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - return [] - - # take the fuzzy search sub-set of data and search it literally - df = self.df.loc[fuzzy_identifiers].copy() - - literal_identifiers = self.literal_search(text, df) - if len(literal_identifiers) == 0: - log.debug( - f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - return fuzzy_identifiers - - # append any fuzzy identifiers that were not found in the literal search - literal_id_set = set(literal_identifiers) - remaining_fuzzy_identifiers = [ - _id for _id in fuzzy_identifiers if _id not in literal_id_set] - identifiers = literal_identifiers + remaining_fuzzy_identifiers - - log.debug( - f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") - return identifiers diff --git a/activity_browser/bwutils/sensitivity_analysis.py b/activity_browser/bwutils/sensitivity_analysis.py index 8dfbed818..1a12f8cc0 100644 --- a/activity_browser/bwutils/sensitivity_analysis.py +++ b/activity_browser/bwutils/sensitivity_analysis.py @@ -8,15 +8,16 @@ import os import traceback from time import time -from loguru import logger +from logging import getLogger import bw2calc as bc import numpy as np import pandas as pd -import bw2data as bd from SALib.analyze import delta -# from ..settings import ab_settings +from activity_browser.mod import bw2data as bd + +from ..settings import ab_settings from .montecarlo import MonteCarloLCA, perform_MonteCarlo_LCA try: @@ -27,7 +28,7 @@ from bw2calc import GraphTraversal - +log = getLogger(__name__) def get_lca(fu, method): @@ -35,7 +36,7 @@ def get_lca(fu, method): lca = bc.LCA(fu, method=method) lca.lci() lca.lcia() - logger.info(f"Non-stochastic LCA score: {lca.score}") + log.info(f"Non-stochastic LCA score: {lca.score}") # add reverse dictionaries lca.activity_dict_rev, lca.product_dict_rev, lca.biosphere_dict_rev = ( @@ -56,7 +57,7 @@ def filter_technosphere_exchanges(lca, cutoff=0.05, max_calc=1000): for e in res["edges"]: if e.consumer_index != -1: # filter out head introduced in graph traversal technosphere_exchange_indices.append((e.producer_index, e.consumer_index)) - logger.info( + log.info( "TECHNOSPHERE {} filtering resulted in {} of {} exchanges and took {} iterations in {} seconds.".format( lca.technosphere_matrix.shape, len(technosphere_exchange_indices), @@ -77,7 +78,7 @@ def filter_biosphere_exchanges(lca, cutoff=0.005): finv = inv.multiply(abs(inv) > abs(lca.score / (1 / cutoff))) biosphere_exchange_indices = list(zip(*finv.nonzero())) explained_fraction = finv.sum() / lca.score - logger.info( + log.info( "BIOSPHERE {} filtering resulted in {} of {} exchanges ({}% of total impact) and took {} seconds.".format( inv.shape, finv.nnz, @@ -139,7 +140,7 @@ def drop_no_uncertainty_exchanges(excs, indices): if exc.get("uncertainty type") and exc.get("uncertainty type") >= 1: excs_no.append(exc) indices_no.append(ind) - logger.info( + log.info( "Dropping {} exchanges of {} with no uncertainty. {} remaining.".format( len(excs) - len(excs_no), len(excs), len(excs_no) ) @@ -213,7 +214,7 @@ def get_CF_dataframe(lca, only_uncertain_CFs=True): "CF: " + bio_act["name"] + str(bio_act["categories"]) ) - logger.info( + log.info( "CHARACTERIZATION FACTORS filtering resulted in including {} of {} characteriation factors.".format( len(data), len(lca.cf_params), @@ -229,10 +230,10 @@ def get_parameters_DF(mc): if bool(mc.parameter_data): # returns False if dict is empty dfp = pd.DataFrame(mc.parameter_data).T dfp["GSA name"] = "P: " + dfp["name"] - logger.info(f"PARAMETERS: {len(dfp)}") + log.info(f"PARAMETERS: {len(dfp)}") return dfp else: - logger.info("PARAMETERS: None included.") + log.info("PARAMETERS: None included.") return pd.DataFrame() # return emtpy df @@ -329,10 +330,10 @@ def perform_GSA( except Exception as e: traceback.print_exc() # todo: QMessageBox.warning(self, 'Could not perform Delta analysis', str(e)) - logger.error("Initializing the GSA failed.") + log.error("Initializing the GSA failed.") return None - logger.info( + log.info( f"-- GSA --\n Project: {bd.projects.current} CS: {self.mc.cs_name} " f"Activity: {self.activity} Method: {self.method}", ) @@ -420,12 +421,12 @@ def perform_GSA( # self.Y = np.log(np.abs(self.Y)) # this makes it more robust for very uneven distributions of LCA results if np.all(self.Y > 0): # all positive numbers self.Y = np.log(np.abs(self.Y)) - logger.info("All positive LCA scores. Log-transformation performed.") + log.info("All positive LCA scores. Log-transformation performed.") elif np.all(self.Y < 0): # all negative numbers self.Y = -np.log(np.abs(self.Y)) - logger.info("All negative LCA scores. Log-transformation performed.") + log.info("All negative LCA scores. Log-transformation performed.") else: # mixed positive and negative numbers - logger.warning( + log.warning( "Log-transformation cannot be applied as LCA scores overlap zero." ) @@ -439,7 +440,7 @@ def perform_GSA( # perform delta analysis time_delta = time() self.Si = delta.analyze(self.problem, self.X, self.Y, print_to_console=False) - logger.info( + log.info( "Delta analysis took {} seconds".format( np.round(time() - time_delta, 2), ) @@ -456,7 +457,7 @@ def perform_GSA( self.df_final.reset_index(inplace=True) self.df_final["pedigree"] = [str(x) for x in self.df_final["pedigree"]] - logger.info("GSA took {} seconds".format(np.round(time() - start, 2))) + log.info("GSA took {} seconds".format(np.round(time() - start, 2))) def get_save_name(self): save_name = ( @@ -473,15 +474,11 @@ def get_save_name(self): return save_name def export_GSA_output(self): - from ..settings import ab_settings - save_name = "gsa_output_" + self.get_save_name() self.df_final.to_excel(os.path.join(ab_settings.data_dir, save_name)) def export_GSA_input(self): """Export the input data to the GSA with a human readible index""" - from ..settings import ab_settings - X_with_index = pd.DataFrame(self.X.T, index=self.metadata.index) save_name = "gsa_input_" + self.get_save_name() X_with_index.to_excel(os.path.join(ab_settings.data_dir, save_name)) diff --git a/activity_browser/bwutils/settings.py b/activity_browser/bwutils/settings.py deleted file mode 100644 index 43f54a161..000000000 --- a/activity_browser/bwutils/settings.py +++ /dev/null @@ -1,110 +0,0 @@ -import copy -import json -import bw2data as bd -import bw2data.signals as bw_signals -import blinker - -from activity_browser.bwutils.filesystem import get_project_ab_path, get_appdata_path - -defaults = { - "startup": { - "brightway_directory": str(bd.projects._base_data_dir), - "saved_brightway_directories": [str(bd.projects._base_data_dir)], - "startup_project": "default", - "shown_panes": ["Databases", "Impact Categories", "Calculation Setups"], - "shown_pages": ["Welcome", "Parameters", "Settings"], - }, - "appearance": { - "theme": "default", - "pane_tab_position": "bottom", - }, - "metadatastore": { - "caching_enabled": True, - "searcher_enabled": True, - }, - "plugins": { - "enabled_plugins": [], - } -} - - -class Settings: - _instance = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance._initialized = False - return cls._instance - - def __init__(self): - if self._initialized: - return - self._initialized = True - - self.global_config = {} - self.virtual_config = {} - self.project_config = {} - - self.load_global_settings() - self.load_virtual_settings() - self.load_project_settings() - - self.changed = blinker.Signal() - - bw_signals.project_changed.connect(self.load_project_settings) - - def __getitem__(self, key): - if key in self.virtual_config: - return self.virtual_config[key] - if key in self.project_config: - return self.project_config[key] - if key in self.global_config: - return self.global_config[key] - if key in defaults: - return defaults[key] - raise KeyError(f"Setting '{key}' not found in any configuration level.") - - def __setitem__(self, key, value): - if isinstance(key, tuple): - key, subkey = key - else: - subkey = "global" - - if subkey == "global": - self.global_config[key] = value - elif subkey == "project": - self.project_config[key] = value - else: - raise KeyError("Subkey must be 'global' or 'project'") - - def save(self): - global_path = get_appdata_path() / "settings.json" - json.dump(self.global_config, open(global_path, "w"), indent=4) - - project_path = get_project_ab_path() / "settings.json" - json.dump(self.project_config, open(project_path, "w"), indent=4) - - self.changed.send() - - def load_global_settings(self): - global_path = get_appdata_path() / "settings.json" - self.global_config = json.load(open(global_path)) if global_path.exists() else copy.deepcopy(defaults) - - def load_project_settings(self, *args, **kwargs): - project_path = get_project_ab_path() / "settings.json" - self.project_config = json.load(open(project_path)) if project_path.exists() else {} - - def load_virtual_settings(self): - pass # Implementation later based on environment variables - - def restore_defaults(self): - self.global_config = copy.deepcopy(defaults) - global_path = get_appdata_path() / "settings.json" - json.dump(self.global_config, open(global_path, "w"), indent=4) - - self.project_config = {} - project_path = get_project_ab_path() / "settings.json" - project_path.unlink(missing_ok=True) - - diff --git a/activity_browser/bwutils/strategies.py b/activity_browser/bwutils/strategies.py index f23967fb7..183402a2b 100644 --- a/activity_browser/bwutils/strategies.py +++ b/activity_browser/bwutils/strategies.py @@ -2,7 +2,7 @@ import hashlib import json from typing import Collection -from loguru import logger +from logging import getLogger from bw2io.errors import StrategyError from bw2io.strategies.generic import (format_nonunique_key_error, @@ -15,7 +15,7 @@ from ..bwutils.errors import ExchangeErrorValues from .commontasks import clean_activity_name - +log = getLogger(__name__) TECHNOSPHERE_TYPES = {"technosphere", "substitution", "production"} BIOSPHERE_TYPES = {"economic", "emission", "natural resource", "social"} @@ -29,26 +29,6 @@ "location", ) -def metadatastore_link(data: list) -> list: - from .metadata import MetaDataStore - mds = MetaDataStore() - - for act in data: - for exc in act.get("exchanges", []): - match = mds.match( - name=exc.get("name"), - database=exc.get("database"), - categories=exc.get("categories"), - unit=exc.get("unit"), - product=exc.get("reference product"), - location=exc.get("location"), - ) - if len(match) == 1: - exc["input"] = match.index[0] - - return data - - def relink_exchanges_dbs(data: Collection, relink: dict) -> Collection: """Use this to relink exchanges during an actual import.""" @@ -172,7 +152,7 @@ def relink_exchanges(exchanges: list, candidates: dict, duplicates: dict) -> tup # Commit changes every 10k exchanges. transaction.commit() except (StrategyError, bd.errors.ValidityError) as e: - logger.error(e) + log.error(e) transaction.rollback() return (remainder, altered, unlinked_exchanges) @@ -185,7 +165,7 @@ def relink_exchanges_existing_db( This means possibly doing a lot of sqlite update calls. """ if old == other.name: - logger.info("No point relinking to same database.") + log.info("No point relinking to same database.") return assert db.backend == "sqlite", "Relinking only allowed for SQLITE backends" assert other.backend == "sqlite", "Relinking only allowed for SQLITE backends" @@ -215,7 +195,7 @@ def relink_exchanges_existing_db( exchanges, candidates, duplicates ) db.process() - logger.info( + log.info( "Relinked database '{}', {} exchange inputs changed from '{}' to '{}'.".format( db.name, altered, old, other.name ) @@ -225,7 +205,7 @@ def relink_exchanges_existing_db( def relink_activity_exchanges(act, old: str, other: bd.Database) -> tuple: if old == other.name: - logger.info("No point relinking to same database.") + log.info("No point relinking to same database.") return db = bd.Database(act.key[0]) assert db.backend == "sqlite", "Relinking only allowed for SQLITE backends" @@ -252,7 +232,7 @@ def relink_activity_exchanges(act, old: str, other: bd.Database) -> tuple: exchanges, candidates, duplicates ) db.process() - logger.info( + log.info( "Relinked database '{}', {} exchange inputs changed from '{}' to '{}'.".format( db.name, altered, old, other.name ) @@ -273,25 +253,14 @@ def alter_database_name(data: list, old: str, new: str) -> list: # Note: this will only alter database if the field exists in the exchange. if exc.get("database") == old: exc["database"] = new - for p in ds.get("parameters", []): + for p, d in ds.get("parameters", {}).items(): # Any parameters found here are activity parameters and we can # overwrite the database without issue. - p["database"] = new + d["database"] = new if ds.get("processor", (None, None))[0] == old: ds["processor"] = (new, ds["processor"][1]) return data -def alter_exchange_database_name(data: list, linking_dict: dict[str, str]) -> list: - """For ABExcelImporter, go through data and replace all instances - of the `old` database name with `new` in exchanges only. - """ - for ds in data: - for exc in ds.get("exchanges", []): - # Note: this will only alter database if the field exists in the exchange. - if exc.get("database") in linking_dict: - exc["database"] = linking_dict[exc["database"]] - return data - def hash_parameter_group(data: list) -> list: """For ABExcelImporter, go through `data` and change all the activity parameter diff --git a/activity_browser/bwutils/superstructure/dataframe.py b/activity_browser/bwutils/superstructure/dataframe.py index d61a50979..8ecf63d6e 100644 --- a/activity_browser/bwutils/superstructure/dataframe.py +++ b/activity_browser/bwutils/superstructure/dataframe.py @@ -10,15 +10,13 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication, QPushButton -from activity_browser.bwutils.metadata import MetaDataStore from ..errors import ScenarioDatabaseNotFoundError +from ..metadata import AB_metadata from ..utils import Index from .activities import data_from_index from .file_dialogs import ABPopup from .utils import SUPERSTRUCTURE -metadata = MetaDataStore() - def superstructure_from_arrays( samples: np.ndarray, indices: np.ndarray, names: List[str] = None @@ -47,7 +45,7 @@ def superstructure_from_arrays( def superstructure_from_scenario_exchanges(scenarios: dict[str, dict[int, float]]): - from activity_browser.bwutils.commontasks import exchanges_to_sdf + from activity_browser.bwutils import exchanges_to_sdf from bw2data import Edge scenarios = transpose_scenarios_to_exchange_ids(scenarios) @@ -63,7 +61,7 @@ def superstructure_from_scenario_exchanges(scenarios: dict[str, dict[int, float] def regular_exchange_to_sdf(exchange_id: int, scenarios: dict[str, float]): - from activity_browser.bwutils.commontasks import exchanges_to_sdf + from activity_browser.bwutils import exchanges_to_sdf exc = bd.Edge(bd.Edge.ORMDataset.get_by_id(exchange_id)).as_dict() df = exchanges_to_sdf([exc]) @@ -75,7 +73,7 @@ def regular_exchange_to_sdf(exchange_id: int, scenarios: dict[str, float]): def mf_exchange_to_sdf(exchange_id: int, scenarios: dict[str, float]): - from activity_browser.bwutils.commontasks import exchanges_to_sdf + from activity_browser.bwutils import exchanges_to_sdf exc = bf.MFExchange(bf.MFExchange.ORMDataset.get_by_id(exchange_id)) @@ -125,7 +123,7 @@ def arrays_from_indexed_superstructure( ) -> Tuple[np.ndarray, np.ndarray]: result = np.zeros(df.shape[0], dtype=object) - meta = metadata.dataframe["id"] + meta = AB_metadata.dataframe["id"] meta.index = meta.index.to_flat_index() id_df = pd.merge(df, meta, left_on="input", right_index=True).rename(columns={"id":"input_id"}) @@ -287,8 +285,8 @@ def exchange_replace_database( changes = ["from database", "from key", "to database", "to key"] # Load all required databases into the metadata - metadata.add_metadata(replacements.values()) - meta = metadata.dataframe + AB_metadata.add_metadata(replacements.values()) + metadata = AB_metadata.dataframe for idx in df.index: df.loc[idx, changes] = exchange_replace_database( diff --git a/activity_browser/bwutils/superstructure/excel.py b/activity_browser/bwutils/superstructure/excel.py index 2701b7c61..d2cbb415e 100644 --- a/activity_browser/bwutils/superstructure/excel.py +++ b/activity_browser/bwutils/superstructure/excel.py @@ -2,14 +2,14 @@ from ast import literal_eval from pathlib import Path from typing import List, Union -from loguru import logger +from logging import getLogger import openpyxl import pandas as pd from .utils import SUPERSTRUCTURE - +log = getLogger(__name__) def convert_tuple_str(x): @@ -24,7 +24,7 @@ def get_sheet_names(document_path: Union[str, Path]) -> List[str]: wb = openpyxl.load_workbook(filename=document_path, read_only=True) return wb.sheetnames except UnicodeDecodeError as e: - logger.error("Given document uses an unknown encoding: {}".format(e)) + log.error("Given document uses an unknown encoding: {}".format(e)) def get_header_index(document_path: Union[str, Path], import_sheet: int): @@ -45,7 +45,7 @@ def get_header_index(document_path: Union[str, Path], import_sheet: int): e.__traceback__ ) except UnicodeDecodeError as e: - logger.error("Given document uses an unknown encoding: {}".format(e)) + log.error("Given document uses an unknown encoding: {}".format(e)) wb.close() raise ValueError("Could not find required headers in given document sheet.") diff --git a/activity_browser/bwutils/superstructure/file_dialogs.py b/activity_browser/bwutils/superstructure/file_dialogs.py index 1ba5906dd..34bbf6b54 100644 --- a/activity_browser/bwutils/superstructure/file_dialogs.py +++ b/activity_browser/bwutils/superstructure/file_dialogs.py @@ -1,6 +1,7 @@ import pandas as pd from qtpy import QtCore, QtWidgets +from ...ui.icons import qicons """ The basic premise of this module is to contain a series of different popup menus that will allow the user @@ -226,8 +227,6 @@ def abQuestion(title, message, button1, button2): An ABPopup instance that provides the basic format and dialog for the popup window. Further manipulation of the object and execution (via .exec_()) is performed upon instantiation """ - from ...ui.icons import qicons - obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() obj.setWindowTitle(title) @@ -271,8 +270,6 @@ def abWarning(title, message, button1, button2=None, default=1): An ABPopup instance that provides the basic format and dialog for the popup window to provide a warning. Further manipulation of the object and execution (via .exec_()) is performed upon instantiation """ - from ...ui.icons import qicons - obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() obj.setWindowTitle(title) @@ -323,8 +320,6 @@ def abCritical(title, message, button1, button2=None, default=1): An ABPopup instance that provides the basic format and dialog for the popup window to provide a warning. Further manipulation of the object and execution (via .exec_()) is performed upon instantiation """ - from ...ui.icons import qicons - obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() obj.setWindowTitle(title) diff --git a/activity_browser/bwutils/superstructure/file_imports.py b/activity_browser/bwutils/superstructure/file_imports.py index 5185cf7cc..ea4ec3fae 100644 --- a/activity_browser/bwutils/superstructure/file_imports.py +++ b/activity_browser/bwutils/superstructure/file_imports.py @@ -2,13 +2,13 @@ from abc import ABC, abstractmethod from pathlib import Path from typing import Optional, Union -from loguru import logger +from logging import getLogger import pandas as pd from ..errors import * - +log = getLogger(__name__) class ABFileImporter(ABC): @@ -75,7 +75,7 @@ def database_and_key_check(data: pd.DataFrame) -> None: ) raise IncompatibleDatabaseNamingError() except IncompatibleDatabaseNamingError as e: - logger.error(msg) + log.error(msg) raise e @staticmethod @@ -103,7 +103,7 @@ def production_process_check(data: pd.DataFrame, scenario_names: list) -> None: ) raise ActivityProductionValueError() except ActivityProductionValueError as e: - logger.error(msg) + log.error(msg) raise e @staticmethod @@ -126,7 +126,7 @@ def na_value_check(data: pd.DataFrame, fields: list) -> None: ) raise InvalidSDFEntryValue() except InvalidSDFEntryValue as e: - logger.error(msg) + log.error(msg) raise e @staticmethod diff --git a/activity_browser/bwutils/superstructure/manager.py b/activity_browser/bwutils/superstructure/manager.py index fa007fb49..83167c589 100644 --- a/activity_browser/bwutils/superstructure/manager.py +++ b/activity_browser/bwutils/superstructure/manager.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import itertools from typing import List, Optional, Union -from loguru import logger +from logging import getLogger import numpy as np import pandas as pd @@ -21,7 +21,7 @@ from .file_dialogs import ABPopup from .utils import SUPERSTRUCTURE, _time_it_, guess_flow_type - +log = getLogger(__name__) EXCHANGE_KEYS = pd.Index(["from key", "to key"]) INDEX_KEYS = pd.Index(["from key", "to key", "flow type"]) @@ -119,7 +119,7 @@ def _combine_columns_intersect(self) -> pd.Index: absent.update(cols.symmetric_difference(scenario_columns(df))) cols = cols.intersection(scenario_columns(df)) for name in absent: - logger.warning( + log.warning( "The following scenario is not found in all provided files and is being dropped: {}".format( name ) @@ -346,7 +346,7 @@ def remove_duplicates(df: pd.DataFrame) -> pd.DataFrame: """ duplicates = df.index.duplicated(keep="last") if duplicates.any(): - logger.warning( + log.warning( "Found and dropped {} duplicate exchanges.".format(duplicates.sum()) ) return df.loc[~duplicates, :] @@ -362,7 +362,7 @@ def build_index(df: pd.DataFrame) -> pd.MultiIndex: """ unknown_flows = df.loc[:, "flow type"].isna() if unknown_flows.any(): - logger.warning( + log.warning( "Not all flow types are known, guessing {} flows".format( unknown_flows.sum() ) @@ -499,7 +499,7 @@ def check_scenario_exchange_values(df: pd.DataFrame, cols: pd.Index): critical.exec_() raise ScenarioExchangeDataNotFoundError elif nas.any(axis=0).any(): - logger.warning( + log.warning( "Replacing empty values from the last loaded scenario difference file" ) if not is_numeric_dtype(np.array(_df.loc[:, cols])): diff --git a/activity_browser/bwutils/superstructure/mlca.py b/activity_browser/bwutils/superstructure/mlca.py index 89396263a..c5e5e8810 100644 --- a/activity_browser/bwutils/superstructure/mlca.py +++ b/activity_browser/bwutils/superstructure/mlca.py @@ -3,16 +3,15 @@ import numpy as np import pandas as pd -import bw2data as bd from qtpy.QtWidgets import QPushButton from activity_browser.mod import bw2data as bd +from activity_browser.bwutils import AB_metadata from ..commontasks import format_activity_label from ..errors import ScenarioExchangeNotFoundError from ..multilca import MLCA, Contributions from ..utils import Index -from ..metadata import MetaDataStore from .dataframe import (arrays_from_indexed_superstructure, filter_databases_indexed_superstructure, scenario_names_from_df) @@ -23,7 +22,6 @@ except ModuleNotFoundError: pass # removed in bw25 -metadata = MetaDataStore() class SuperstructureMLCA(MLCA): """Subclass of the `MLCA` class which adds another dimension in the form diff --git a/activity_browser/bwutils/superstructure/utils.py b/activity_browser/bwutils/superstructure/utils.py index f5a7c5cc8..76a3e00b1 100644 --- a/activity_browser/bwutils/superstructure/utils.py +++ b/activity_browser/bwutils/superstructure/utils.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- import time -from loguru import logger +from logging import getLogger import pandas as pd from activity_browser.mod import bw2data as bd - +log = getLogger(__name__) # Different kinds of indexes, to allow for quick selection of data from # the Superstructure DataFrame. @@ -79,7 +79,7 @@ def _time_it_(func): def wrapper(*args): now = time.time() result = func(*args) - logger.info(f"{func} -- {time.time() - now}") + log.info(f"{func} -- {time.time() - now}") return result return wrapper diff --git a/activity_browser/bwutils/utils.py b/activity_browser/bwutils/utils.py index 3b082a81f..95f972893 100644 --- a/activity_browser/bwutils/utils.py +++ b/activity_browser/bwutils/utils.py @@ -4,6 +4,7 @@ import numpy as np import peewee as pw +from stats_arrays import UncertaintyBase import bw2data as bd from bw2data.backends import ActivityDataset, ExchangeDataset @@ -33,19 +34,6 @@ def deletable(self): except pw.DoesNotExist: return False - @property - def uncertainty(self): - uncertainty_keys = { - "uncertainty type", - "loc", - "scale", - "shape", - "minimum", - "maximum", - "negative", - } - return {k: v for k, v in self.data.items() if k in uncertainty_keys} - def as_gsa_tuple(self) -> tuple: """Return the parameter data formatted as follows: - Parameter name diff --git a/activity_browser/info.py b/activity_browser/info.py index fcf3ba875..03af4642c 100644 --- a/activity_browser/info.py +++ b/activity_browser/info.py @@ -1,4 +1,11 @@ +import ast +import os.path from importlib.metadata import PackageNotFoundError, version +from logging import getLogger + +from .utils import safe_link_fetch, sort_semantic_versions + +log = getLogger(__name__) # get AB version try: @@ -6,5 +13,55 @@ except PackageNotFoundError: __version__ = "0.0.0" -# supported EI versions -__ei_versions__ = ["3.4", "3.5", "3.6", "3.7", "3.7.1", "3.8", "3.9", "3.9.1"] + +def get_compatible_versions() -> list: + """Get compatible versions of ecoinvent for this AB version. + + Reads this file on github repo: activity-browser/better_biosphere_handling/compatible_ei_versions.txt'. + Converts file content to available ecoinvent versions for each version of AB. + Finds the correct available versions for this AB version, if failing to read version, + the lowest version in the file is chosen. + """ + try: + # read versions + versions_URL = "https://raw.githubusercontent.com/LCA-ActivityBrowser/activity-browser/main/activity_browser/bwutils/ecoinvent_biosphere_versions/compatible_ei_versions.txt" + page, error = safe_link_fetch(versions_URL) + if not error: + file = page.text + else: + # silently try a local fallback: + log.debug( + f"Reading online compatible ecoinvent versions failed " + f"-attempting local fallback- with this error: {error}" + ) + file_path = os.path.join( + os.path.dirname(__file__), + "bwutils", + "ecoinvent_biosphere_versions", + "compatible_ei_versions.txt", + ) + with open(file_path, "r") as f: + file = f.read() + all_versions = ast.literal_eval(file) + + # select either the latest lower version available or if none available the lowest version for safety + sorted_versions = sort_semantic_versions(all_versions.keys()) + for ab_version in sorted_versions: + if sort_semantic_versions([__version__, ab_version])[0] == __version__: + # current version is higher than or equal to tested AB version: + ei_versions = all_versions[ab_version] + break + else: + ei_versions = all_versions[sorted_versions[-1]] + + log.debug( + f"Following versions of ecoinvent are compatible with AB {__version__}: {ei_versions}" + ) + return ei_versions + + except Exception as error: + log.debug(f"Reading local fallback failed with: {error}") + return ["3.4", "3.5", "3.6", "3.7", "3.7.1", "3.8", "3.9", "3.9.1"] + + +__ei_versions__ = get_compatible_versions() diff --git a/activity_browser/mod/README.md b/activity_browser/mod/README.md deleted file mode 100644 index a1d206dc2..000000000 --- a/activity_browser/mod/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# mod - -Monkey-patches and modifications to third-party libraries used by Activity Browser. - -## Overview - -This module contains patches and modifications to external libraries to fix bugs, add features, or adapt functionality for Activity Browser's specific needs. These modifications are applied at import time. - -## Directory Structure - -- **`bw2analyzer/`** - Patches for brightway2-analyzer -- **`bw2io/`** - Patches for brightway2-io -- **`ecoinvent_interface/`** - Patches for ecoinvent-interface -- **`peewee/`** - Patches for peewee ORM -- **`pyprind/`** - Patches for pyprind progress bars -- **`tqdm/`** - Patches for tqdm progress bars - -## Key Files - -- **`__init__.py`** - Imports all patched modules, replacing the original imports -- **`patching.py`** - Core patching utilities and helpers - -## How It Works - -When Activity Browser imports this module, it automatically imports the patched versions of external libraries. These patches are typically applied to: - -1. **Fix bugs** that haven't been addressed upstream -2. **Add Qt integration** for progress bars and UI elements -3. **Adapt functionality** to work better within a GUI context -4. **Add features** needed by Activity Browser but not available in the base libraries - -## Import Pattern - -The module is imported early in Activity Browser's initialization: - -```python -import activity_browser.mod.bw2analyzer as bw2analyzer -import activity_browser.mod.bw2io as bw2io -``` - -This ensures that the patched versions are used throughout the application. - -## Development Notes - -- Patches should be minimally invasive -- Document why each patch is needed -- Consider contributing fixes upstream when appropriate -- Test patches thoroughly as they modify external library behavior -- Keep patches up-to-date with upstream library versions - -## Warning - -Modifying third-party libraries can lead to maintenance challenges. Use this approach sparingly and only when: -- The issue can't be solved in Activity Browser code -- Upstream changes are not accepted or released -- The modification is essential for Activity Browser functionality - -Always prefer upstream contributions over local patches when possible. diff --git a/activity_browser/mod/bw2io/__init__.py b/activity_browser/mod/bw2io/__init__.py index 8f269f5f2..236e1b081 100644 --- a/activity_browser/mod/bw2io/__init__.py +++ b/activity_browser/mod/bw2io/__init__.py @@ -1,4 +1,4 @@ -from loguru import logger +from logging import getLogger from bw2io import * @@ -7,35 +7,33 @@ - +log = getLogger(__name__) def ab_bw2setup(version): - - raise Exception("This function is deprecated.") - import bw2io as bi from activity_browser.mod.bw2io.importers.ecospold2_biosphere import ABEcospold2BiosphereImporter from activity_browser.info import __ei_versions__ + from activity_browser.utils import sort_semantic_versions from .migrations import ab_create_core_migrations ab_create_core_migrations() version = version[:3] - if version == __ei_versions__[0][:3]: - logger.info(f"Installing biosphere version >{version}<") + if version == sort_semantic_versions(__ei_versions__)[0][:3]: + log.info(f"Installing biosphere version >{version}<") # most recent version bio_import = ABEcospold2BiosphereImporter() else: - logger.info(f"Installing legacy biosphere version >{version}<") + log.info(f"Installing legacy biosphere version >{version}<") # not most recent version, import legacy biosphere from AB bio_import = ABEcospold2BiosphereImporter(version=version) bio_import.apply_strategies() - logger.info("Writing biosphere database") + log.info("Writing biosphere database") bio_import.write_database() - logger.info("Writing LCIA methods") + log.info("Writing LCIA methods") create_default_lcia_methods() # patching biosphere @@ -53,7 +51,7 @@ def ab_bw2setup(version): ] for patch in patches: - logger.info(f"Applying biosphere patch: {patch}") + log.info(f"Applying biosphere patch: {patch}") update_bio = getattr(bi.data, patch) update_bio() diff --git a/activity_browser/mod/bw2io/ecoinvent.py b/activity_browser/mod/bw2io/ecoinvent.py index 9f950ca3d..adb5e6563 100644 --- a/activity_browser/mod/bw2io/ecoinvent.py +++ b/activity_browser/mod/bw2io/ecoinvent.py @@ -1,4 +1,4 @@ -from loguru import logger +from logging import getLogger from bw2io.ecoinvent import * @@ -7,7 +7,7 @@ from activity_browser.mod.ecoinvent_interface.release import ABEcoinventRelease from activity_browser.mod.bw2io.importers.ecospold2_biosphere import ABEcospold2BiosphereImporter - +log = getLogger(__name__) def ab_import_ecoinvent_release(version, system_model): @@ -32,27 +32,27 @@ def ab_import_ecoinvent_release(version, system_model): name="biosphere3", filepath=lci_path / "MasterData" / "ElementaryExchanges.xml", ) - logger.info("Applying strategies") + log.info("Applying strategies") bio_import.apply_strategies() - logger.info("Writing biosphere database") + log.info("Writing biosphere database") bio_import.write_database() bd.preferences["biosphere_database"] = "biosphere3" # importing ecoinvent through a ecospold2 importer that implements a progress_slot - logger.info("Importing ecoinvent") + log.info("Importing ecoinvent") db_name = f"ecoinvent-{version}-{system_model}" ei_import = SingleOutputEcospold2Importer( dirpath=str(lci_path / "datasets"), db_name=db_name, biosphere_database_name="biosphere3", ) - logger.info("Applying strategies") + log.info("Applying strategies") ei_import.apply_strategies() - logger.info("Writing ecoinvent database") + log.info("Writing ecoinvent database") ei_import.write_database() # importing all LCIA methods - logger.info("Gathering LCIA methods") + log.info("Gathering LCIA methods") lcia_file = ei.get_excel_lcia_file_for_version(release=release, version=version) sheet_names = get_excel_sheet_names(lcia_file) @@ -69,11 +69,11 @@ def ab_import_ecoinvent_release(version, system_model): raise ValueError( f"Can't find worksheet for characterization factors; expected `CFs`, found {sheet_names}" ) - logger.info("Extracting LCIA methods") + log.info("Extracting LCIA methods") data = dict(ExcelExtractor.extract(lcia_file)) units = header_dict(data[units_sheetname]) - logger.info("Mapping LCIA methods") + log.info("Mapping LCIA methods") cfs = header_dict(data["CFs"]) CF_COLUMN_LABELS = { diff --git a/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py b/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py index b9f644479..f8d9fb065 100644 --- a/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py +++ b/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py @@ -2,24 +2,11 @@ from bw2io.importers.ecospold2_biosphere import * import pyprind -from loguru import logger +import logging import os from activity_browser.info import __ei_versions__ - - -def sort_semantic_versions(versions, highest_to_lowest: bool = True) -> list: - """Return a sorted (default highest to lowest) list of semantic versions. - - Sorts based on the semantic versioning system. - """ - return list( - sorted( - versions, - key=lambda x: tuple(map(int, x.split("."))), - reverse=highest_to_lowest, - ) - ) +from activity_browser.utils import sort_semantic_versions class ABEcospold2BiosphereImporter(Ecospold2BiosphereImporter): @@ -71,7 +58,7 @@ def extract_flow_data(o): lci_dirpath = os.path.join(os.path.dirname(mod.__file__), "ecoinvent_biosphere_versions", "legacy_biosphere") # find the most recent legacy biosphere that is equal to or older than chosen version - for ei_version in __ei_versions__: + for ei_version in sort_semantic_versions(__ei_versions__): use_version = ei_version zip_fp = os.path.join( lci_dirpath, f"ecoinvent elementary flows {use_version}.xml.zip" @@ -124,5 +111,5 @@ def apply_strategies(self, strategies=None, verbose=True): self.apply_strategy(func, verbose) def write_database(self, *args, **kwargs): - logger.info("Writing Biosphere database") + logging.getLogger(__name__).info("Writing Biosphere database") super().write_database(*args, **kwargs) diff --git a/activity_browser/static/README.md b/activity_browser/static/README.md deleted file mode 100644 index 630b86ffd..000000000 --- a/activity_browser/static/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# static - -Static resources for the Activity Browser application. - -## Overview - -This directory contains all static assets used by Activity Browser including HTML templates, stylesheets, icons, fonts, JavaScript libraries, and other non-code resources. - -## Directory Structure - -- **`css/`** - Cascading Style Sheets for HTML views -- **`database_classifications/`** - Database classification mappings and schemas -- **`fonts/`** - Font files used in the application -- **`icons/`** - Application icons in various formats and sizes -- **`javascript/`** - JavaScript libraries and scripts for web views -- **`startscreen/`** - Start screen assets and templates - -## HTML Templates - -- **`activity_graph.html`** - Template for activity relationship graph visualization -- **`navigator.html`** - Base navigator template -- **`sankey_navigator.html`** - Sankey diagram visualization template -- **`spinner.html`** - Loading spinner template -- **`tree_navigator.html`** - Tree structure navigator template - -## Purpose - -These static resources support: - -1. **Visualization** - Interactive graphs, Sankey diagrams, and charts -2. **Branding** - Application icons and logo -3. **Styling** - Consistent look and feel across web views -4. **Classification** - Database and activity classification systems -5. **User Experience** - Welcome screens, loading indicators, navigation - -## Web Views - -Activity Browser embeds web views (Qt WebEngine) for rich interactive visualizations. These HTML templates use JavaScript libraries to render: - -- Force-directed graphs showing activity relationships -- Sankey diagrams for flow visualization -- Tree navigators for hierarchical data exploration -- Interactive charts and plots - -## Resource Loading - -Static resources are accessed via: - -```python -from pathlib import Path - -static_dir = Path(__file__).parent.resolve() / "static" -icon_path = static_dir / "icons" / "main_icon.png" -``` - -## Maintenance - -When adding new static resources: -- Place files in the appropriate subdirectory -- Ensure proper licensing for third-party assets -- Optimize file sizes (compress images, minify CSS/JS) -- Document dependencies and versions for JavaScript libraries -- Include resources in `MANIFEST.in` for packaging diff --git a/activity_browser/static/css/README.md b/activity_browser/static/css/README.md deleted file mode 100644 index b44887c96..000000000 --- a/activity_browser/static/css/README.md +++ /dev/null @@ -1,245 +0,0 @@ -# css - -Cascading Style Sheets for Activity Browser's HTML views. - -## Overview - -This directory contains CSS files that style the HTML-based visualizations and web views in Activity Browser. These stylesheets control the appearance of graphs, Sankey diagrams, tree navigators, and other interactive visualizations. - -## Files - -- **`navigator.common.css`** - Common styles shared across navigators -- **`navigator.css`** - Base navigator styles -- **`activity_graph.css`** - Activity relationship graph styles -- **`sankey_navigator.css`** - Sankey diagram visualization styles -- **`tree_navigator.css`** - Tree structure navigator styles - -## Purpose - -These stylesheets provide: -- **Consistent appearance** - Unified look across all visualizations -- **Responsive design** - Adapt to different window sizes -- **Interactive styling** - Hover effects, selections, highlights -- **Theme support** - Match Activity Browser's overall design -- **Accessibility** - Readable colors, proper contrast - -## Common Patterns - -### Node Styling -```css -.node { - fill: #4a90e2; - stroke: #2c5aa0; - stroke-width: 2px; - cursor: pointer; -} - -.node:hover { - fill: #5da5ff; - stroke-width: 3px; -} - -.node.selected { - stroke: #ff6b6b; - stroke-width: 4px; -} -``` - -### Edge/Link Styling -```css -.link { - stroke: #999; - stroke-opacity: 0.6; - fill: none; -} - -.link:hover { - stroke-opacity: 1; - stroke-width: 3px; -} -``` - -### Text Styling -```css -.label { - font-family: Arial, sans-serif; - font-size: 12px; - fill: #333; - pointer-events: none; -} -``` - -## navigator.common.css - -Shared styles for all navigators: -- Layout and positioning -- Controls and buttons -- Tooltips -- Loading indicators -- Error messages - -## activity_graph.css - -Styles for activity relationship graphs: -- Node appearance (activities) -- Edge appearance (exchanges/relationships) -- Labels and annotations -- Graph controls (zoom, pan) -- Legend styling - -## sankey_navigator.css - -Styles for Sankey diagrams: -- Flow paths (width proportional to amount) -- Node boxes -- Flow colors (by category) -- Tooltips showing values -- Legend and scale - -## tree_navigator.css - -Styles for tree structures: -- Tree nodes (collapsible) -- Branches/connections -- Expand/collapse icons -- Indentation levels -- Selection highlighting - -## Color Schemes - -### Default Colors -- **Primary**: Blue (#4a90e2) -- **Secondary**: Green (#2ecc71) -- **Warning**: Orange (#f39c12) -- **Error**: Red (#e74c3c) -- **Neutral**: Gray (#95a5a6) - -### Category Colors -Different colors for flow types: -- **Technosphere**: Blue -- **Biosphere**: Green -- **Production**: Orange -- **Substitution**: Purple - -## Responsive Design - -Stylesheets adapt to window size: - -```css -@media (max-width: 768px) { - .node { - /* Smaller nodes on small screens */ - r: 4px; - } - - .label { - /* Smaller text on small screens */ - font-size: 10px; - } -} -``` - -## Interactive States - -### Hover States -Visual feedback when hovering: -```css -.interactive:hover { - opacity: 0.8; - cursor: pointer; -} -``` - -### Selection States -Highlight selected items: -```css -.selected { - stroke: #ff6b6b; - stroke-width: 3px; - filter: drop-shadow(0 0 5px rgba(255, 107, 107, 0.5)); -} -``` - -### Disabled States -Gray out disabled elements: -```css -.disabled { - opacity: 0.4; - cursor: not-allowed; -} -``` - -## Animations - -Smooth transitions: -```css -.node { - transition: all 0.3s ease; -} - -.link { - transition: stroke-width 0.2s ease, stroke-opacity 0.2s ease; -} -``` - -## Tooltips - -Styled tooltips for data display: -```css -.tooltip { - position: absolute; - background: rgba(0, 0, 0, 0.8); - color: white; - padding: 8px 12px; - border-radius: 4px; - font-size: 12px; - pointer-events: none; - z-index: 1000; -} -``` - -## Development Guidelines - -When modifying CSS: - -1. **Test in web view** - Not just browser (Qt WebEngine may differ) -2. **Use CSS variables** - For easy theme changes -3. **Mobile-first** - Design for smallest screens first -4. **Performance** - Avoid expensive effects on many elements -5. **Accessibility** - Maintain contrast ratios (WCAG AA) -6. **Cross-browser** - Test in different rendering engines -7. **Documentation** - Comment complex selectors -8. **Organization** - Group related styles -9. **Naming** - Use clear, descriptive class names -10. **Validation** - Run through CSS validator - -## CSS Variables - -Use CSS custom properties for theming: -```css -:root { - --primary-color: #4a90e2; - --text-color: #333; - --background-color: #fff; - --hover-opacity: 0.8; -} - -.node { - fill: var(--primary-color); -} -``` - -## Browser Compatibility - -Ensure compatibility with Qt WebEngine: -- Test rendering in actual application -- Check vendor prefixes -- Verify CSS feature support -- Test on all platforms (Windows, macOS, Linux) - -## Resources - -- [MDN CSS Reference](https://developer.mozilla.org/en-US/docs/Web/CSS) -- [CSS-Tricks](https://css-tricks.com/) -- [Can I Use](https://caniuse.com/) - Feature compatibility -- [D3.js Styling](https://d3js.org/) - SVG styling patterns diff --git a/activity_browser/static/icons/README.md b/activity_browser/static/icons/README.md deleted file mode 100644 index ec6d698ec..000000000 --- a/activity_browser/static/icons/README.md +++ /dev/null @@ -1,214 +0,0 @@ -# icons - -Application icons and graphical assets. - -## Overview - -This directory contains all icon files used throughout Activity Browser, including the application icon, toolbar icons, menu icons, and node type indicators. - -## Directory Structure - -- **`main/`** - Main application icon in various sizes and formats -- **`context/`** - Context menu icons -- **`nodes/`** - Node type icons (for graph visualizations) -- **`metaprocess/`** - Meta-process related icons - -## File Formats - -Icons are typically provided in multiple formats: -- **PNG** - Raster format with transparency (various sizes: 16x16, 24x24, 32x32, 48x48, 256x256) -- **SVG** - Vector format (scalable without quality loss) -- **ICO** - Windows icon format (contains multiple sizes) -- **ICNS** - macOS icon format - -## main/ - -Main application icon used for: -- Application window icon -- Taskbar/dock icon -- Desktop shortcut icon -- About dialog -- Installer icon - -Sizes provided: -- 16x16 - Taskbar, title bar -- 24x24 - Small toolbar buttons -- 32x32 - Medium toolbar buttons, list views -- 48x48 - Large icons -- 256x256 - High DPI displays, splash screen -- 512x512 - macOS Retina displays - -## context/ - -Icons for context menu actions: -- Copy -- Paste -- Delete -- Edit -- Open -- Save -- Export -- Import -- Search -- Refresh -- Settings - -## nodes/ - -Icons representing different node types in graphs: -- Activity nodes -- Product nodes -- Biosphere flow nodes -- Technosphere flow nodes -- Waste flow nodes -- Substitution nodes - -## metaprocess/ - -Icons for meta-process operations: -- Aggregation -- Disaggregation -- Grouping -- Filtering - -## Icon Loading - -Icons are loaded via `activity_browser/ui/icons.py`: - -```python -from activity_browser.ui.icons import get_icon - -# Load icon by name -icon = get_icon("save") - -# Use in action -action = QAction(get_icon("open"), "Open", parent) - -# Use in button -button = QPushButton(get_icon("delete"), "Delete") -``` - -## Icon Themes - -Activity Browser may support multiple icon themes: -- Light theme (dark icons on light background) -- Dark theme (light icons on dark background) -- High contrast theme (for accessibility) - -## Icon Design Guidelines - -When creating or modifying icons: - -### Size and Resolution -- Provide multiple sizes (16, 24, 32, 48, 256) -- Ensure clarity at smallest size (16x16) -- Use even dimensions for pixel-perfect rendering -- Support high DPI (2x, 3x scales) - -### Style Consistency -- Match existing icon style -- Use consistent line weights -- Maintain similar level of detail -- Use the same color palette - -### Visual Clarity -- Simple, recognizable shapes -- Clear at small sizes -- Sufficient contrast -- Not too much detail - -### Accessibility -- Work in light and dark themes -- Sufficient contrast ratios -- Distinct shapes (not just color differences) -- Test with colorblindness simulators - -### File Optimization -- Optimize PNG files (use tools like pngcrush) -- Clean up SVG files (remove unnecessary elements) -- Use transparency appropriately -- Keep file sizes small - -## Color Palette - -Standard colors used in Activity Browser icons: -- **Primary**: Blue (#4a90e2) -- **Success**: Green (#2ecc71) -- **Warning**: Orange (#f39c12) -- **Error**: Red (#e74c3c) -- **Info**: Cyan (#3498db) -- **Neutral**: Gray (#95a5a6) - -## Platform-Specific Icons - -### Windows -- Use ICO format for application icon -- Provide 16, 32, 48, 256 sizes in single ICO file -- Follow Windows icon guidelines - -### macOS -- Use ICNS format for application icon -- Provide 16, 32, 128, 256, 512, 1024 sizes -- Follow macOS icon guidelines -- Support Retina displays - -### Linux -- Use PNG for application icon -- Provide standard sizes: 16, 24, 32, 48, 64, 128, 256 -- Follow freedesktop.org icon naming spec -- Install to appropriate directories - -## Icon Attribution - -If using third-party icons: -- Check license compatibility (LGPL-compatible) -- Provide attribution if required -- Document source and license -- Consider creating custom icons instead - -## Tools for Icon Creation - -Recommended tools: -- **Inkscape** - Free vector graphics editor (SVG) -- **GIMP** - Free raster graphics editor (PNG) -- **ImageMagick** - Batch processing and conversion -- **icon-resizer** - Generate multiple sizes from SVG - -## Updating Icons - -When updating icons: -1. Edit source SVG file -2. Export to required PNG sizes -3. Optimize files -4. Generate platform-specific formats (ICO, ICNS) -5. Update icons in all directories -6. Test in application on all platforms -7. Verify high DPI rendering -8. Check light and dark themes - -## Icon Resources - -Free icon sources (check licenses): -- [Font Awesome](https://fontawesome.com/) -- [Material Icons](https://material.io/icons/) -- [Feather Icons](https://feathericons.com/) -- [Heroicons](https://heroicons.com/) - -## Testing Icons - -Test icons: -- At all sizes (16px to 256px) -- On different backgrounds -- With different themes -- On high DPI displays -- On all platforms -- In actual UI contexts - -## Maintenance - -Keep icons: -- Up-to-date with design trends -- Consistent with application style -- Optimized for performance -- Properly licensed -- Version controlled diff --git a/activity_browser/static/icons/exchanges/link.png b/activity_browser/static/icons/exchanges/link.png deleted file mode 100644 index 7742c7dbfda87df57e5704803d60abac87eb5733..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31393 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
w&qgc~o{km?4j ztXI9fFSs5^v3yR${0l7C>O8POAC~QXxlPAQ&@(qj9oG77I3FNZ1T|z*C-6tl z+F|@mxZ>mWfFC@kw+@Dc7KFII|troBlj!-Ak6kEGfJL# z3VyH}i#D;}CGT?fST7k`qV)9i8mG(Ycu}mcZcbLf1JYu~z*>6!xz*kKRZ}C?w>T2_ zRUS)p>!1=a`KLtkTT?~zn~Dd^Tg<_s4E1VRWb~}3S4{`xU*osP1NS$tySj?tpI>9l zPL9~##u5qsDiX^hDkTyca+#HtzGV}lUjyDFOP752xs=csKRb{|5b|>u#_$D(CZYa` z#vs#f@=1K3GFYsPBQL!|VzYVu8y&30e9M)jkSMXJ52)G$kHr<~uFc0PJh&Z@*gC1I zss>MpHU&hI#5`gc)|B26e(AP3YvZQQasxj1#dAVMmaklRQ>_GNRo( z1cCyOAEE3zK^e&eh@rFBgAk_@(o|Lg~0@?3DH-6%_TGj0%9!3B?Ed~H; z4>JbBM#KKn7F|R7TURT38qmwX!v{_d1!yi;+`KU}vc|c#{Y%)VY+(lH77Wjl99VA@ zy(}U(RgRjJ+FM1bbnKYHA8IO?LdCI2S-Ek*FtU$)!ayoJ_SSQBVyoAubmMCx1O;->F4R<1_gg-FN6lywg zM_xMlD5r3jyAH*vV(+=a~$^S5b@#j0hlqc9AhvQkEH7C3YWbE2$@xJgY)1PV5D>p z%Zb@N9LZOB{9Tj=o$*&BsH^L4oaFO=DAw|x-|rJ?LAnNr415%Pyu*Y71b_VX=D)<9&y zwbb_R0%~~?{D+A(VVJ#(WN&p63v(nLRtk*RdWYm%y5O`OIMEOsZpAd}#!C;F9>HY6 zxA70f-ugZYF(rStK&X50Cx-)!l7D=ArdV?5n|>SW=*;Z4EPz47_UV!UKR7WH*G?&% z15*%aFLvJQ)LZSyU5*|Q#(*!DGA+{XdJv@jk1%=>tB8L&D4-@yz9=;~A|_ATvIy-i zNT@dMWtKG*>q#0`=27Pk4Lwj4J-U@D49=iiK3Ga?RSg7kX*9IFkg58B0Fj~oQ@u`T)Vh5r0AG)z3gdjSYP5zEe& zU2?RbG`h<|;TYGDJuP23;dDZJ8Y)0O^bk;_emaSNoIh~d)A0U=Q%s-BmSKIysZN8T z2YYcGmn=*|p;m2gzq+6k6uItQGZj>nb^!r0IL#pSCo-z=Yo)Zt^O=p`>vw(R%u#M1 zPAhw^?p(~IB2K-Zx-^LYyfQms&{Xc6-}YSC+Yl?@O1NXeSutQ`ViRn{f1gu2nS<|A zm2CF6PR;8m*SM-DMLyQ1b8poFrRN+)aoN_7{IbhvsWn-to*3>+Fqh*M(uU(|H^~$w76+)`}}#U5t&3sUvJtyy7fmoZxgq%Q_8AsdqZfnHW9l` zAe!Lg4lq=}nY$*>e2U>>b7UB~w`;{2c+kgol7$p7{xxv^$`^D+h zVg13TS{YE~nY_lZ-H%4Vu#l&KG1s>IeP&05ct54TbH$~cL@0oxUMLpQpx0;Q;&oa1 zIG$Vet}9wksv%%g*Jhz813OG&LJ976=@b#?>BLlc-{M;-Cmhxyj`r5i%fIzd%Is_A zfC_vn>UxY{KRRNwO2z1v+s%I^6TGsMR&PRCa8?|hE4rP6BQ|JE9wYqP zqvecZ{jDazV<1OvEtQA#t|{`~ywjo-9P!AGTT3w%wys0xSGGB#z&o>8kX2u6nc+C&AEg3w|fncfL>`%rkA~MwtOzKt+#) z3p)*%42NJ{`YZNI_YKVP$`yFHG}}RU*q?WFr=m{cYf5)~R{2S#cW=jdg7&H?tRhK* zr$fYQm=OU!NkpxB!sJzTdx@LNmz)-duwbmT$Etzb$X||HLdyP$t2!N6As+Z=MW**ZL^9+pI%aqI|4@ZnaK@ zwTk@udcvu>)XTQP6GQV6ceb1U_!Mv_T$#S$74$8Qc`mS-2rwMq+tXs8^tdj4G(0eX z#YZ<+5q#UV-us(>qA!VGwq2lW;B80wMs&v>p&a)nxQ(*1WEJQQtsAKA1xRoNs*WIC zpaIRlE>1W|G;(RFSKJc>BDjMP^#e$wB!Btxc675$W4r0V%hO^D{ewO|uvuD7pwK`T zDd9FaFj;t3A&u+r5f+F~`Th)h_v7Wp^%^XDiPzCH@9u2st*x4yjhErE*`>!eSpR9u zHYb5DfBo+b+tUTx6;iV3S>;asSqs3g_2+SfiOD%3@9XhePab<498Ap^%?XCSz5oUR z+K$BTOKqKmpRw31I>x#$KXayk0*uSdY0xqZ@=^A2(VLWKYEmlrx#-ro+o|E`O$oV=m3x3u>L$LUF zt_h-#oAg|6 zRTv12SewG;*CKDGvFj?l?-PaM?u$)AYfWnEqHUjy?%iwrhl*WEyCWc%Q%No(eZVix zAUFEi#_=^VEwbR2u!}25^zIuItv`p;M9{uMPEeiaAUXYa#6FCLKTr{*v*Bilw*AImUk- z%(YvPG2L(mVWBC0L8o@f&1IDcyC`k>&e7DJ8!zP`P0(TD)iorE&@m&AB@yGYYkMuX zbvl>@ge&tR`A&m=NMURK$=xtRfAMmr2OUszTq*U^Zsn?^Qp!Z$3u0sanK7^bF=Xj@ z=G)(GQI0i+hp^RE$>4kN0gy|F*SI5+JRv=oTeJN^3|oLmIGn_oulDj*o_4X*z*PQ(4TryOW1pthNb@A(}y8_A~Bs)j-~EMdSS8LTT~vgmk5&G@$R8( zI7S5#$&K&dZ(Pc5oC=3}vgvk`ZZV5iJ@`=|n<+s*%Y1FQZMd*s*O`oR`~>mG+dG8j zhRgKzIs++{b%kZuUPnzs#XW9<@ z;^@}#3pNW>?5cVOD~Z+>n?d^trExR;fl`btSm8Q%R4}7TYY2UbG|BF7gsBn! z>yTXZz{CR7NJ(NV1BkQQwsM8D#uGQ*^wElY>%5b4z^&6+v)r}-A;CTly>s75W>64<9}*M9_2l*K(Y>hs0?9b$# zcJQ$*2>5;Jft7iyF)8js@Ekjh8HU#vQ~msx#S@b^~Hy_t^G}OcYV}`wjpP0Sq;hJ#9>exx(f9Zkkt{rx8+_qqkvuvCMIaX-nPpC zzQwb9v<=n9C2YMK)Z8hT$ddIiHIPnBEGCnGzJt$ifj5$L0Yczs?I?Gb@rDlXMf4bVnBkXC!7~c@gn*Yn|Md$|V`BzsRjvNZ4>83Ll#Cs!;Qd2Ri*pv1@9>Hz@YUcIJ;S^O+zpgoV#}f&nerM-WsY_JBqn9YC3`JyfHN1h%+CGlhgN99WR}bng1azuX)P`ju z;jsB~Jxl3++p7MrmB^6yA=hAVB^Au{@bh7Z0aUn+heRPU`d6?RRV`0#zEX}$aj>>& z!PLl`0UCt!8i*E*#TWEo8yI#dK?a+4&*X;Mm2wEYTruye*H8Fs&^KKqVE~Y%8Ar5J zpcIyw81R4vp2D!!2@FJREl9_^hn`q(3vwk*h6P?>$vtUG!d6?E;JU$ z<~1nV&yzluZB9w#q14IU^_tV}sT;S;wS5#DmP43=uZW8)LYILz(;lUxV9o}A%hXq# zmOlZ;hPA&M>}rLAJ~Q%4JN8|~lxVrS4!U30=>N>0==(rK-tl*Dic4Q#--F0iL)ik0 z9sEK-T?vl#DxBSDl^h)f^O1ow?enM_3dyqNtH&xk*_Ahb$V(6AOo!(b7K#F&FnUSB zPypU{UvKX!JEkd*YpBqlP*R`Q>=Ln-2&dsWfdMMp4;EC(+lS!tNJ7tcKTvXH3>e&V z$>S=%qmeC&rBz6ds_%o(|`Ea~Adf)t@4eQsFBvy`=M9x#+QfQlST*WwZO<7Ew zCR0=i0<@?DR0}vnfX^Zh34Lp5S0%v?I%@1TsT0Fks*vA&58D`#Qt8NbDq~e?J5+iV zGfp2}|3$d?${;J{@&z-sy{v*oQR?7Eu1flROPER^4PggVij8986ik1u$0W!AqqHi| z+f04l$UEOE@s>|6-Zr~3;I5F|^Lof4-g0#~El(mzg1t$Wm(qoAtLRApR{x*}6tj<4 zi{aK4UlO^A#Owqf99H=~!|yT5orzv{*IUG#tk{k&zj7D5lMZp)G1>PsMV>Z6XBAQ( z9*bkRV~(koZF0M^yAg-1G{j)u-^@&{0rNwS$hQ|cqvSP30=*)e>}x}vqbWA1IFLFF zTn?kEE zHDcNJz2409+2XQUc*vH*;A8%0Ogwi4lE9iE#G!}^_}bA3>w^|=GZj)mnT($aQGiJu zE~o{taSy7PKiFN~ zt(v*`!~{tt`=qXL6o;S~2ua_k!pC?l+8Y&B!55C&jHgwS;ku@-Cac+!{Mc-awvF3$ zqUfsL|CjSOIa99+oL)#v4S__fVi%B>usdcUOu+u}X1c*;$96l2=`*6FCG#>Vm^j>4 zHL4iU1Hyh{z%6fl1KCQiKL1sydNx$*Z1@U2fUKETH z2}!;iwtU9^e0rJ*tsL3!W63l~o4Gb9E`rflz#@Q>uNCif*QCzALS zt`&8HLAfiY&-E4^fxb6?gY*^oCKnw64p2MF47_&)jv5RwDD;nt>$~&n?gSjh< z#7D~>p|8wRk6f!C2H>7`S21OBaq*+|Zt{{ep|~Q3xhRI!r?sDS#V?jQSdFbIStLJP zBye6m5ZOhq2@4;<3>aqg5z$`z7`e`JQOPAJ`~9TQ@79JFuFJ3Wx3`Y0mO{p(#5K)C zr7fE8;!(oD7`A}4T6K*IBkSb(qsl>ypKY>+m*40ieP=~kBBp*an91!rJ$JnIA?d(v zDU6g^;D^0EX|gI2B?B{3D1=se^yc@SaR0Pw?;ZW3ZmvnkeP|-hHS&UPaisBcL1@I} z=-}f<*m?MTWIcc-Cv2pOT#2PvE9XhjPG)pm)Ta6SsO;%Ptk3P~HLslT(^;@XUE7_i z`5d_D1=M2`9wUdgf3c(`?OmW60`|^H>M!~&M|*{X3eie+PPKe|XWaW&#%ANxN6+#- zi(va#h%1uy-Tdh~FC2!t;gaFzA4a-EUZ_WDKGMoHr5gT{e$3qK(Pc&uQnutVTKcQB z_wjT)1)C6i$^JMH)IQY{>ggcu{B@iI*tD>u*(Wh0Iu;<^#JqQv_Nx6eQo+@4p!jDW z{P*C%Y&!8TLeFtb@HBDXU;2~mB_7nq){dNer?L=4VQika`5Q<*8H|t^0l(v9BpV=C z-^5sp`F%UrrVoXMDs#u&1{JuL&Y*B# zT`i?G`s-Ug14zE<}jL z=!oYw?QWd11rCqmXdxx$cQs+Zd(kqga{j%KjO@pwSP4JB{qGEhML*FyM z%xtFk**2!}2pj4=i{~-5I`!2U$iO{-2Ef~6Pmro52K}XOzjt!}?rzTnD!h&V7x7uP z2>~a<>zK)jkUp1#K&L{XP`sw#x29*AWlCTv-yQs#K3KU%m;(eV2_>g;kbp3m4NXzG zS9hGV)TLVH2X4zwrfXx(Et|xZju1sDszZcB8 zY!ZGNp3kfQI}0=+z{6MEqx~CnJk7K!cDY<%^?q;PG-m%;V<`uVZ`02wL8ZD@-J3L< zPGPg*-=rcoi!6+9G!jvZq^3J7a-?bIv@`iz*iMowja1b^PTH4{m^d%&zZCQP2(Mmb z|Kxff%D?43SHo&$vFn_jqzA=XrB%5_#+?0r zkJiM_?@OrL)k;1ebR}H4E(ZtJ=>M5F7>jn8kND$I8-rDhF34%w+7SUx7ddIPUj0pJ z+4WH@2(bBu$Y$^j)=xi~S(AR~kfeU!iw7ku36pa^r2+GfB#}R|ks6%X2U&(|DGsA$ z+DqW$UmXb`+_mnuGk_io(7~Z~xL&Oz4tQ<%LtzDaW}iYz=lO1rTFx`*2Coer=nq34 z#RmS3aA!2b71T@?i1V`>r7=(1u=-WYcjY&vnsN zw}+{J&m-r29BhfYGHEgRLUsEx?$>o&YqobUiRdZNTl<&VDs!Rh(6Z%5TzFF8B4?oA9b-9`b%Hk5((F zr>MW-zZD;ygvL8pn_lZT)(i}AkRS?`T8NybXMI64!;WZ${O(2?s<|@z_d0k1)jGE;Rv_FAA%sKY0 zgWgc~lIN?@%AlGg<6xN4me>+^@Gi}yNXuCjxdW{NG8{&okv;wJv3%2yuoFQMRXTS>K=fb?fE@EG<$TC_HhS({2V*frU44twj>^4><)1tL-3UNTxg1+- z>Bfmm#&Uu%Z^%&s$}YlL1G#hYWZz?Ru1MFp%FRR66RT?|w5C%UJ+ZegjUq8)y2f_Ygr4~^Zcn>|e`*$_Mi5de7m)MNKSwCUmlor4 zTt-2o$%tRF&F`Gaswx?){;pP7<92QgFZKJ%qdLOa#RI&>01y=>8*lwz)ChWz zN%FT=3;tfNNkKwA4sXZ;1VFFc--egwU&a(M|95mM z9DVFXuujJ-#5tm#Bz2!k>ZeW^8;OhfGNojOzqs0ChWq$xyAsr%N1>sXlf`t<8wh{9 zM!1rYw@~^V?zX+|0>xX66EYrm#7(`PsQ;3l-ADL`i_5r0u{Fo<+eVIg1ogP>X_=c( zCbdTJxb+w*(~!D6*m>R}S0bTnFTA@Ga0GWpMoI0p(&|SpQ{N}*^#%!>%oi%~0KNNqC`Bm7n9tf5{mQ-CJI}=RJ;Q0RCJF)9(vh>HUzk=o2)1zBJxK}XA zfHDtEm8XGbR5H?=nbLRp{i6uW>IL(JMd-Dqy*@>mFy9Z>DsmQcqt|91ywZVvN2KBFuVT5KZ+to_;%~?_kqEl4C>lx&lZIe)=V?-+(1};sI z6m!AGb9l@RJF%^)tl13B1G?w;DKSTQLkbqr(K^>G?G_pW6>bk_I-Sx4HzMFcf@`_Z z7Vj#+U*1a1AkMx#9KvXz#)- z?*vCID=x!r?aSe`i|w-v!0qc$ta(YD&-Qw}h(ye9?{lB9(MI%97^%MgK$J zef^p)X;@l2`cugF!^3>0nLen&WW!jA|8K1LNs*ladSZIyx&?B(i6RS??7gAuqI&F6`ReLFCxHm<2jv-rH~M=^i=Jn^x!=`{(LCK7 zAwb5U;ghIz#GX8so%rvb($L%mdWYqfl`OT7&wQigIJ(YgS!`~+{zqG~B|>!w+03kR z;%l=(we&~>P+_dW?U?-c@$8+2kB5fQH5$_1Jea8;4@Z}Gth)Kq1M0vm69*bPm!bo+ z@i4&8fBG&>+aLl>fvP+uafUTh&t$vRc$?FySJsOFr z8{@z%^$fYn+e`uM<3Liv1ON505K;q8r%Ci@N}MAyo;&m+EwUvp!_3Uow6=Ho@frsp zEW1Vm5|E`UfGY0>9N%LKnNUIkbg=GXhV3BtOhVHM3Krtt1soKc2M<_=(6N!^TD zuO+#H?kF&zvWr-p;m`xd7d{^>y>(a>EkB~F0~lxsS^pqenwD%sbQeD$m!Y#^fQ`-E z3!pE19-1|UiP_R;r5!o@)&zNRG)N`T6;-y&YIMB*k z!1ykZ&Lt2cqo~#w83yhPw@yb2@*H8KPiDcVlQnQ5Uc6c! z(DRSf3Jo?NVy!18- z=1#g~{kV)Rq!&m_35q`Cy|xQdPg$NlmqYTRbqY@1$YZ&Y_g05+Cf!uW#H1#qW)<$W z#Dg4n9XiN#=I7=*35O)lzJy_XFZeT8)8#liUXCA%@QdFwZt1A~O|Yohpso`Xu;Ig8 oLDF?PNEk$oaSIq7I7cF*i};H5)+@dP1pZWSY2GZjVfpm`0XSo)!vFvP diff --git a/activity_browser/static/icons/exchanges/unlink.png b/activity_browser/static/icons/exchanges/unlink.png deleted file mode 100644 index 6d681334f621d6b740be25830640987117d43533..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34142 zcmXtA1yEGq+uo&1Qb1Y+ge4?@NP~cYq_hYKNJ)1{?W%N&pfre-bSWS$DM-4Mf^_%N zx%-{;n8E zc2_g;fYX%`N)|dkTA+e$`1_uGk3BG{gVw(Fm0z;?SvipjVyyqdi?}i$juD_gqB{vPkLcF{(d#(qBX<`{AAP|we_S%S`s3xZuMl7 zw@+1D2NoeWI-$&WLnX}AU*1A2K>95O>mI@82=?nJ7X=`DXV>U@M@L6-(-z^?EvtVQ z5|Fo_p_XE#rKNR7e6Bem+&B_Y1viNdEyy#R{ZGjUVFtX%zus|aJT05OdC0I00egQk z4>2$_Gz1%uOcuehEGtJ^KgZNFT7n)i``b{lY&rpRzy(g>59nuQ%fk~ z(#IU$Tv2h)1>7C-@9u$*#296j)g@`$3k(w6jzeTR0@%Aa6eFd<3z+*7AgUeC0tvTD zm9;!r?rSYKtC}yJh2*|xYMJIC=f$4(eQ+%9P zZ4+Z-Wz_+qz7hXc&JuE+X<$#&TrJY;{|P-SpQWf(^~Uj6BSK|7;Xi2|ļ-*YSY z_#8v^`g5xGn+iuq^7Q&G?ovmcmd(cg{bxZ03OqOen#Mv_9$%4R8&40#^77Zy6) zADfu?#i{h~F`Kn{Zt{+a?WjURI3M4nCcVwuc0XvL?VuD87?JI)y4!iP#l$N4gaw+n}GT0_0-_9BwLVvdIP< z$rtJ5XpiGo{5umfEuNcFX2>tokk~H)g0UP=?i4iNO;l^Xk<$6^F3YPKZWGyX^f{X^ z*E#+I^eRMNT|-0H%q;fm>T1{m*jsfA`SCF9A;Sv_$8i{>D6Y4p$zTV!qD(mXR zIg=hOxoBvRre|ct(FZsGd_=+?A{=nE-qs|^*#2(`4(g~8o7PK6CJB8!+dUZUL5^JT zNU5>l(!)0+v>%sK@p0osf zrQ&kGqt_qd<>ghGec$bws2L8{0VJ|thy~;)ALv2|zgvySUd?}h<1zApZ~=i!E~I_t z8h!_7nXLJAHn3-UcLq0NzqQa|ZE|9wm6-nDImF*(jMGlin_bPI&kn-d9J8;vVRO!A{=2aXckXYo4FD3?=8jKbc7Orw-I^t_o|gRqG~SW za^Z7ce8)(xlKgOwJ$jAoEyw=FRwYv+w27nO%Fotz0=KBL?3YsQZQ8%r%9T=`NszK~nt z9*P=s@}%q)5jS+MdRYVcwIx?>_D*g|-*D8?qq*-U&*dhCO z9T&K6sJOB+$EtoPx!Doj*`bYPiMV{A}l#p;|`+uUku-~TG_`_G({Mui})r{(irsCvr z6fp2aU70Q)J)088ex0!-4{_yHyu)nqB%dl1*eCa!oBhR+id>B7&YErUw zA<-lW#fBVfQf&6PNk;JKO7&?=%FK+}rg$Vrg3Pb=>0yZs3i&I_VVT3&L~cv=sFRZu zGXKg+Hox+G;^C1zJKX`ydiD$vzeu&w5reV|>=>S_{Fw&y3!B!UfI_N%|0}qJU#AJ<8>spo{>%rk6_7bIE8)Di1 zi^Lp{Mr_jm(_%6-HFXMj`7hd}{+@=Dub@r75|(TgQOmdP+4~Y~EOdmC#-;QHftU6O zyz87)hh(hZ$grcWdt!y3nJxBi#~VL;*}cy2ajVfmJd9E>IVB~>(Jlev>O3eEs~0c5 z+n^8}eBEgjC4anEq>m{s%I;qrK8mzXkfo!4=Fg7v3gIALkjy;t=kw&8Quw>7tcM?+_!UeFNqT8|D zmlS+_e0++V>S&lYhfi+?!lXny$ziOC05!6?@xxQ^vHK^^O@GWcE6#Jd*Mwf7DXYZCEZ$ASNEuZY6yEpU*8-r zih|%x!}_O~Cs%1@x+W%38uz~8J$(4kE=}gi%VBYIJb166j`DO%sS9R)( z0}ZSHJhZlu_!n)vF3SJPnl{D@4`G#B76}LOE8+d3PLU`byeGCV5qqSbo}L3j#yIn@ zHjqVpqmJ0q=?g!bBbd)Hni?J){BQS)knENvIL$X?Afv72z*|SHJMdHlpMF`t5%Jg51vP&h|Y}D4RE32!)nqz7UEd~xbi+^tg2fK7EDJ}I_&yC=%=Lo$; zOCC}AV>KP?N4347oeOQyvkKZe5VOvfho=bG@rESVn2&a*C&|3v%+kKjd(HwqST_n( z=A=HCg>XH3BrAEYU(6OGZ_N!ViT~O~LgZ-mv$czA#f2iVh8my=4u}Jh^VBMXwSwLv zNEm!mLtR~6$YW7i^se@^n{g_6zn?^wf=Ah)8hvr(2Z}Tk82WCQi8-S7LRB|j?SlthldYaOxL-H&hBogO}~DPll=ZYgMfR}+^?96#f&k$-o#Kv_MnT6 zqO&t=uEEPj$$_;)UB%mm1l#{%{lP8;985+S<$drFTUUOACp**CzB^B?U1eKdc$aV5 zOFbmx<>kd@X{#S^*?UH~XfBteU$3s(&4gU;luYBhkrTWj9L~63`O=LZ8xd10xtPf@ zdPFF`(wghyu>>77=8fV>sZn_cNH7Trau+`aP+yw7iFkP3+o6QFR@}uR`$7ZVrw@CW z+~)hZa||4n)X$rg@1SB@u7j}F^&19GZcVR$4s1|6+55mAMJSRM%YMemQ)*l(zPqZmKccD6EVN~v?? z2(6%!-}%oJ!Cq6h7TbyCZ)Hk}(9I8iN298NB5ogBT39gGd+@kYMng5&o(3m%%I@G= z4JZc&=z$N(Ryv9^#`TaG;$#T-ahO7fEHWqRBGA+Ax z#}HMNguXgIG@Oi?W=vm~up79A%{VIF?Sswx9i&gHEML_e7bN>}QAhEeE_-1wPoRlX zZpELUGm31Z-@DqlnX1MaizjbBSw0&t@G*3lnC7nKeETbRzQ&3r5UJZib7z>gzFk7} z1c}sfum9OCG;ohNqSM$EhE6f_j4JBFfSB0chPifYKKiA>kG6;op8;+4=i=h(;IyS# zMJjSema?}BQd#Kl*+DoUKJ?@%-=QL*pLI2y%F#x*1RTGn|0v$f=~~%xl0I%)Eqf&D zaq`=~sgAH~AWamt5W!aRwDlqK%Y(0=B9T3}jE4xt5=JTCN%1|Vs$X8#@k2kO!rWjI zzm;o2hsmzC{Yf_yeEL@5bVmDNmnxKt3kPhg}yx zh^4VG_L{Ju<}z)au7cy(XXwlYD9EV=X7d}FuUf;6IiQ${Hl0?RBAapvpF*}ZDHjpj6>kl9DDKO1PR4Y>~Bc<{D@U`A0_H*u6fm$tFO zVlpYg8u+J-0%&{L+OLfw;eJ_d^j&S$)l{c{{n+HrmuX7t7aJ?LT25!TN-DQtMVX)Y z=@p1{X-UGucXyYtZ#;#DJYqOWZwTk(-PNf#(r`h}E8XDxSq4mujAgUYd>iZQ?f>$c zwl{8fcei>TL@_eP?^GBB;Mf@Os@F)^`KjO}m+&Q~x&5xb9PL6*v};po9aC0TmWsvo zSEtDi0bNLrj@Dys^_P7|8%V!m6!uyT@RpXsC`F4OL>l^_X=!QQrN=rkxe=^-zt^(P z-_a;oxZNMn<`EF+9?5Nr8{4B*jC?>!e=l}-(;B;Ldg>@;9?~;nN04^=FuybekHOm1 zV>}B9tF5A|;zsHJIeiee>D|C47v7Yj5H<_aQ|!Z1kw@szE6O;Ao$33E{yk4gN$K1f zAx_@Sce#hwQjDa?PZo>$u(%qh7`da@Zv;8wEkN8jcY*Mjc~K&iAY|2dJipu*#k<$E zEz)~R+^|*AL}srCWl1kQzHS^s{5un10!5Et0wBxi(S%)_TnW5kByT4Es7<7s(r5GR zb7|>xsl<6C6$cJD&4?H2MqCADI0}NM;oju3M~^c>pL_ElT>s_a;C)mV&5=_^a401` z0B9JbukVA%*~6lIC$SomjrZ0m44ZAs%E+GmI4P=vo0>8?-?@l^%J78x^V7wQ^VM*G z&fP;#y`R7B?bDM^mC#Nrj@+T*^~QMk)e>9DJ2ke1-letOZMJ`>`Pkf?W!&c)26<@| zDQ|nJub4#N1^lnP{HAO@IeQlYj7!TSNC@=y&V@S_>+Swp$X0;@W|T0)U@!2o8I*NjMFyxYUmZRcvz<$jB$E zJ$^PqKhFyZ_YeZX*U%_hKC7|$4tgwURuN^P6K4*R8#$!%-FMFCjFQ-)PuL;WQg zBFtzvj29<6a92{|J!pyYEZRcUC+HM2>yd41LWzpGT4)$A_f~ zhSKs`Mr-oX7v5izJ)arV)fc~A*9Wo5+_k8+b++Nq&B39%BZwPe`mS$zP~!uCwGD0{ zf+$Qeg_s>ZW~ggPoJ=XN`i-j!4djRa(RA!-f;5ADKWz-;NG^Jk|7ryb2*pTx9$6GZ`R#W zh83fj99{{@1m&+}T4~W=3ifQMd&lJ8dg51axVq#;0u}-{N(+De6C_;_qr-Z{o8wx&sdLE1H%6anT#pnlrnUGh>2aJ%ydewZVbblFg?sq9E5#Qes z@|l89@hcjJk{?ir`wU3Xt^3=Sgzc;O72np0*ip5?CZL8% zIU3hjv2!zy{SBI|ynU%}%3vrIunS^JCWzU4Ld$5^v**KWSMj03z zv*kH3W?gCRRZjyTieoy5;L88ORk_4@lJ(l@rINQFd5Q=eeHCuN`%vr6GZCaKaI(g2 z$++CjyLch&6abBq_8K(UYzcngOGiU!D>4}PPN{urdAV}y0*9{yZIGgA1!vsZ!bcTJ z!$~{P3IE-{uQOwkB3@tZk6Ls|FN~vAY__w*g!|3DB-CDN$E7f+si0h3X2A@QBjWmO z@WPyGd>!c(gSbtdgf~Iaqw1Jm*B+hj*m#&D?vk#nx8nP`d@q(kw8~{#yjDvdprGCh zpe6V`F-J3E&d&x3_b- zm$B+*gP-o)fI(j>LFL-!;AW>F&{YX5Dq;8qi)}rB71_Piy8BZ(Z*(EOu)8d^VWQs6 zf@5Krlp9{MS0n}BJhB(brp35|{@%z(63SvX6^QDlUl1dgn1^s}Z8nDJonCH3rWlIz zCo(c{f2Tg?@&26r4%CjOTxe~U!kVJN=RDInI_L>qW8-==^>Zar=in<;Z%-8qx3J9* zO0F#u_9>`4kqKPt5qsNXf{v*OcL|xXwZp`-Q{ib}fERjj#iyQ`3RoT*4PP7#TuzQ=X&Ixjh2(l|CQ)M-awTV5WnsQr))s-6L~_;GSMdyb+6 zJ3(g)5$@F2m!&T`iP*{W>cRQkve4$%a_vVeDFN_|DN0@z7y3C3Qj>wiA3qO#cvU>+ z)){`oF2fGB2L%P30qP*qQCfS8Sm?199s)Su$yK0u9J^`uv5zE0I~{0wN)Fk%bsn`0 zS9~w9I~1-}kJ+W)FL+k=L*VFJ`gA3MBxid%$qkbvRb?$Rb~+oee_tj3+k zTek1CsEL&zGS(Ko?&=FdEmt}`#c2;Qio_(t^Z`84XY;Omj(rTk^)!lC+68e2yeprb zAZ>FAH8tD!q3`u-aOa8gITL?KG5p}A-4bcCpK0(A+u#7@2fXe2q;srTpNfoN!FSyX z1Tuc~P;Oq{QVxXC;E(&C{}d{pssDLJR)nd-we-45o8eMVw)fe&9Fxb*JDeMV*N)_=qY~*|y-F-N%;CTtdDT5w-M-b=DP ze<+V4z1PsLcK4v84_nyPEG`T!<^;1o{??GhjCl8s8tVlTIP-W{T50r#$-QcQD+s$t zxKviWANSu>S^dneIZ`XQ)OpgGse9Yc#Y%+ood?4*wj5WP%aG-jmE!1G54?7&f$Xf; zl)v{#cqonZZ2E73kW*-!a>m4U-_CRYJ7M7JGJsJ6Pn^q4@|rWYQ>;+NKQ#?W?~94u z5@pX+bZ~H(m>l!T;(%yB_zWZ5&T4dw-r&|3q$COB#eJnPK!TEAeyb$p@QYLZ;1zub z+G};-`{sD502OBCXG-lEpCXa4>i8c$b-o^j=}krjqR|W%NW|Mj#Rp2a+bc<134Vx- zzWAbu6l*+`?{J=0iXcCZJ+l{KP`VO$vE8xghEx5-nF|qDGBF3J0GVvU$3x@@B_c7u z3dq~zKeRA!Ur3D_&&^Ly{(3>;Ywm2rXJ4-AGSJSDYxx8&C#qHf1h7`C_ADbJSx>I= zfUzCMlRjp$5KGztaIFmyOka9(#FpMx>7(Cw|3ek)c;D-Asm)3ntQG`E_T5mQ9x5T) zL>j|86I2_X;p#b&smY`VFbPE;v|j(xK%FcoS~9(cB(o*YPy^c5djzk@x;1Fz=abrBS7uAt@AM*m&J%m(hIxukc` z;p8W47_PwFyhdmdpQ5rJ)&)PesmKiQEz}R;;SAAiq4Y)N_CAR26*1P*nm0}P?2u+d zBa(9?p-)PVy@9R0HRmd)R`Cje!tTS7pr2!dUwP2MDMm*}A6T*e1^aJJCqh)M=qt53 zZc?!C^B`my-Wa@pRAixU{&Hc#9rjAvu9e)bh{wP!j^)V`(sI5eG4<+$l6)C7;+g4q`Ya6la z)0;a#I`mqC8d<%pIThX*{Gw>*{>EHYq?supY=IP$I!~xmyc(8RA{)-U$_dzn(ZLfq z3Y?9;`trViP4jqs6e&mEZb(o5roD^5;>?7z-9@{dg<_Oz{lZ!B=SzqzJ!WEJq6kK0 zS+`>yFWIBNC=UR_W>gOoHUrc%C`Qs%N<-48IUtQ=Lq=8nl2D&4V=XTef73D8lf#=q zZi+&eTa6Kk9IC5d>*{{>%^j6QQE{luA0c&4W6$KVefi3J;{ox(MK`5T%IpNl+0EnA zsyN)+Va&xZHbicCW|!W9s;}bwxbikl+eq8iwgN1$sDXMq?=o1U;?4S3JV7n(RgP!2l0gpaYZb!cWAx1xF8IrEJ|~^ zoU%^nb|}hPWkA7qzpVj4G6<@LB+2?t)>B8h8ilH~8d6RGEDbiP{kE z8MPu&wa?bX^bd+E#Vc;j)OiL}4q%o)2wJ$-Inco~I12a=8gT(w-oyeSb{6^`3vPhJ z+Yi<0;7Ym4^JKDmk&Qe8BM{oq5gjUD87WT{)4hN2Q!vBjL-!nm|14Ck2wC9=8Bjm< zQRP&4#0v5J_5DkU8CfsrgfV#YrgqcEdL3-lmfvwU&+O_<=-?5z3-XCGvV9@m^{SBD z__oFwVM`oPxv124Vc(cXZUZ0?(ws>SH;;)|N0Ea#4QoVxdsIwOO#@ye1^-Uh9CN`6(BlZn~6+57Kj5IAtJlMZy=R45}x5a@of_7cmM(E zH+Dn7bw`1d8ZZ!2KIi&IzF$O}KkT1k>6gBDTy`0@D8JjlEFy}ZHZ?ahYrncn&-=04 zh#a!O)PTN(tb$u5PD1722xdgtaBZ?;X!Fz}eftu`;3RL#!3{3Rb(MG~Xx1?DZ(9#1 zeQZ1H`Xz9x#(l{luIl;04>!Dcb$xn>B@1K~)lCWk=F z%H@8uBJC+L7DlQ_0Zd4tR%MjD?ZxCc?3Puv(~~Qff&52Fo}rZe{qKIVEJMWp`>*&( z-E1~FWoS*jL&G`{YOPl;h-GxlK^-$6E31+HJNO( zxXgQqq3E0B<>A}>WYg^}2<0~3Hl2mk+Dk{;tQ{YGF|LC9zRw3zg_F&%s)$`a%iB*? zS!GjW0G>Yj`S?Bq9i0AcpUpkJuh>$T1&OO*g*R^gM~kM;Gfv6e^gzM5vgO3FTD_!SKRY(j2B z2xn4XS!JoipQo4qyQwn$oy!#jV-1GJo`de5*_j4xMWS|_1;Eu3n5)D0gU5AD{OEz! zs^H6$TEiiT`#ao`sj1I4=CA@KX2|O-MCHTVNyjn}t>=7Z>SBxq&KRbFn)@$CZkxG( z$IqsMr?0Cn>d>GyL%Zi560x@yySu1LLQLzNbwShA0X2q5cWAe3qwtR<WDNkzxL{ z_pp5V1K5h}*!~Z)K(Ok6U2~64<1Ra#DqF%|9=l1cyLB;NJA}F=4$J4?_}Ulu%J7%k)~hM9*Cq}2|#TBcKhc0?agATrnf06go6A1 ze3kQQ_SwSI{4s4mM|{j3ymvu67n(Hn=`K!HK}CgY(;+j$mHT!jii}9csmAtF3W`Hv z=kZl_-H9wk*W6r|1fq4WPd0&m;iAm6z?l==7 zEV<|5Gs$W{W3LEq|2c7Bi896Y8}j^n4GPG@k+y>=iw6&0QJoYK#u;~EIYbfc^OKr1 zxG&U0Ge7?7v5H_>{VXyAuwkC4l8we)oPT%p!ton?N?xo}I58S{c&<3_W6_VXZiof# zodm+kaGDqoCspQUP2SVkT~aJL?QyVHC0|V6N2*wVz0?t0KQ(J`Y_K>36?e%Zhk~tF^^v z^X*LjWXjt6oe&c2S(*y;S8Wh=L&n@~xcYz(bxz1d@jD+Mb3cFb_7p4bc`uxL*`w$} zS{6ReRv6;G@8Log_1PPvWY(x@yT*zWdFm)leG|R0L6F=$KuO%mw}?4gN;vGx=_AQ~ zysoO(Uu!VUAlxCI@Ir$FfV($9OV?BTEcHsGEPGFj$WBU~Ev#$fuIX%{k^$jvYBi4U zQhy9Z|81}6o6jRD1Huqb@-)tC?z*Rb7?O!@vM=2bvl6DctI4B%H)${Nu=3XsF?XiI zqwsy4z1GVLkAq>G8o7{;>czc$Y1<~MC}l;xe#UVssgj;=yNz41PEy>*n6{Mc$uaZSK=n(!qfO9^OW~jEEcs(9(YM8j;@RgyQ<#;@Noc1D@K(T@=O;g)g0R4uL zqOgYxtkiPK8Y7?dUr#)XG*6cHu!vH``q3UIihH8W1eaC~mg3X4m(7P@S=fRM)5NJb zq%Zgx{`sg0uRJ=11Bf04D4MD>Q4=d5Old)Gh-F<}ey)3HE&)?t6Px(#1$pLPkE>8c zGw+a}j5N&V7t`weww4`p6~wjF@6rt#u1HXCCDWi&%N_%^CSAK|4Jt847U+FZbYRBK zUeEDE3! z&Dp)wk-zqyv=2dEFKz#>JzpNS2?NEbqz+Qdk>O>ui8yRq-tlG}#@()?oJ35k^f(*W zvV~@!>|V|y+P_-%qwDkjv6mqJe=aKv&B$%)jbIJrthUjeMSsKnl)dhy~#YWXji-_n17o1&Q^ z>|OL7m|1TKe=MFF%&pxvWhLDK$p`S2x;Dz!twrj$L~Mx2rPgmZyay#5!{V?N{4NuS z2Iqh=wo4yfJpDP+(uNE``ynSL?35R7qG($V3vn&YIX{XlZAGTO7#6h=VTi8aX?pJA zRWIM@#);A?(6dVi1qcj0(}6>}FB{@;uuj&p9nyOjP36{d{GlX-Nef8Na_&Pmdz#1* zhKUgN=25X(WyM^|hlUlpl8VW9qrOq4i;G+LkU3^J7lQnlA_63o<*lt~ZHWG09Sa!& zmO^9FYRv54s%)zONp3|H0IO&Hdl{E@&~2@Mt|sQ@i(vjV8(vSTIMP{|QX>8-cZ5DE zWg|Y5z~@VSJyX$Yd_4J*Kon2QUlga-e@D{cXa&^7T_SbAZw@wkD;=4p-^=g6;L&zpgkPm4;^^X7}!Cc3&*fS!MBWEB4E zm)?><%L|K3&tJc*tZ;L#(wy&fEbI&D^_P|lY2SbYMwy)Zu<;X89x>6;(Xr-xLa_}= zWhH)F!QA3R3z!5|HCTet*AOM8Hz*ONXvUEkW^xiFoFV$oTMmUj?cj@}^Q(vP-*-Ri z_h1<@<$KtC-wlx9HXv1?tvj8A!+Y1(0mE{CE=a%ck7DFncF4s>UDh>|G~})-PX35d z>66J)HCR#mo00L3iE`7pNokNp&gYPZIFdL9gj%&E>kr|RgN0fdSd|E1xI(xKnrBI- z$C90X8@_-PErqP$9r zRs>n7<)!7Yt0CBu@4QnHLS}*4>*sHJwS7YTWSQy9!YqGTCq!&XT1UOO?2xTir|PUJ z)m`F>1;)j7ZE_cVeR&$e^upQM;P)kb2gFY6rVB`JNl?F`nY^T1P6aNf+`+~Mjr*x{ z1!PJUq=G>AyGh@0+E+>(F=@daUWSm6-C z{$NGor`eZ_Ql!ICZ7D<%on!O8)Tp8!6HavUz$P7t)yePCqt*J8$L8S};&{k5u_DoR zG-;dQf5I%(O?(ZmH!0!BbScb_Q*>Z2c&ML1oAbH)hhOnv{rIJoZuj=RBB!)k^7g7HHer#P-2-yef#ZY*!ZIb;)2!@vWqy9#s zG+A!$;%0$jWRaNsZZF)p{k(89YxhnW2Kz_g#RdyEmN{!RAHTj)( z5RvbtK=TGfC??37hf14M zF}>nUa-WSWkdHVt+^Uy^0EAC{(#H|;!L81vJ_du$|7oHdDi0Yjk-bZEdmDsa4ey2H zut+a6`5Y|NpA_=@O|`6&tZ z0od0NYLc*qiyw9%g|XbQ%%)x3JH3qiF6T;z0pcKQ>LNv{>k!P|=}{7PGwe4>Sn?eh z!}5lzfNp!o-<-(9(8Z;^hT*f_;t>&6lAt*0Q^HURY;WDrz4O6gXPQZ~!aqdN@GAjV6GQ#~!C)oX;f?SMlRaQSPtG+_55d zW1?k8Mw0(teS{o{-3WntuUlNRIHgeUG2|4sJcfq!Ool9jT;Mp^D59faU`ZN$uxw!j;-?#f&@a{8dh_RV&vP!Rq z;`%j&>aE_~{OQ)-V%=}2Q`ZMQ3$mBRsIG2z1}?c9Z*W7%NX~?KdJ~{fwt&lEp2Og~ z7rbifmR|OZrlL}H`J!iHS>Jn$#pKirs;fnD=^F{h6wizsD=^=qxZY@cLjWZUZLE-| zXg}JJdPW6bOjKq44rrX*x8gvFG%-FN{Am_Ssc2KtxUZ=Y#73RhRw%pdsBrf-)#>>a z@ytnkc+2lwOJqP$S8V4`0O_SzuQ<9C;=k3tKLa5rz7Uq^O?W=|>}3rMR$U?b=D%XG z*{$_1=|1f;ZJ#?!@d~pdU+BBx!OZWJf~dQipWV=mu-ZFThsZwIf8 zAw%0U`8Dm3VeJ`>%8IwBULPsWh&MT~P1^8-DUQeC@C0e?->;tMgj;)h3Ejzg_TWhG zW=FkJ^88M`Hou$3^Ou4Bk#rI_U@L7vy8xH)+nwQbj@M^KU}<<#JRBd$`tF=TGbb8| zq|ldpy^TIw1{sY~tO;|~f~4CMP&EsWg(tZVB?O8i68W?$bR~scS1hgFfUGpa_f6%e z1`0wc5m|BX3!E)lJ&DqxBE zqxa-@qUUST5uVUgJyX+5X6NX?EC@ADMVGWCNc;d%#%`k?JPmZIxa zn%q~!-7jyMzl}k(IU{#T!C7f6^cQdc4HgNQxE3~b>36s5E@1hiWP>4mzO z-`0b5JO82FUc6|}@|%sa(c3A1Pq~N&QjoZ^vSS}|g*8L^to#SYti4mB%1pOYNZ1{H z*i+7QkM6iO#i>W$N&->D`$GPfI0;IX)a%9q+s%3M+CbcwV;XgSmaZDo&z$Og4CZGo zh0e5^R`93Bp||8if;>dh@@LUwOLIrgKn?}iO`rns04gkOHB_QC?&s@EZVD6=bOq!J zt0gc$oadG4q^Uo|74GE|J$QP^<>{J(`mi|_lLE-};QVAJD_jDCpj3mWh!<9@IVZJ0 z8i&MK9h{YRPRPCg92z?m&+WZ( zO|N&`SAYUI+gYHFl+g02!0jyY9xu|{7B>}5GZR1Gn2|iB^(CrN1qnL6r|Idph&QjH znfjR#YP#9nG#3M(g!7g*R>fsT$6T9+LiPIpOE1ha0uu0knn?%pb^vr_0twYA|8;-5 ziz!k}>54*bO8kN13ZPNv!4QR5=h!Ep5ZSaJKQ3WG@D;T}UePQ#%Y{*TsHN?n+v3y% zz=lUp6iC6WAnI!-AuOsZE0&H9(D=S36pr1m!Y%*8Z!BKvf3`nxS3R0M7Nw5r@+`go zoSX0?Xd;b~Uy44fru?pJ@jc)MaC!X8!xxdCJf*zN*r8#!@~+5p<#Ek*nto zM8jyoJvgE|(HF0tcH&s){m+QM^A?tO0~Oa|-=B8YuI?m%(o{%7j756et% z-a0GfQZ8-)z8#;{zAKXI70>|x1}e3Rk87rjgJsfvF-;UbNyS2m>xPr_dppHl&r77><>a0}c&%NUEe);U+64ha>dtWLEoP zi`XJZE%@h5{@Vk=eIjZJsU;n`sJ?l2=oKdObM&R;*)W2{ z%;;I<$FGw(Hdl0ll2Bxk&LmbxkLR9f$c<4V7Y;5%6vTyKMg1qfn>t0Tu$5R458kRX zHI?Q6B5=9lMx@NM^sIBCx5{TjQ$7Z+OcNM+`nJ8vl_S$vCw1@rsLq_GLG{up3js!~ z1ZEPj>ZfwZy|0@?a~cI9K*i}l|B4XXn||o?(=Q&YH(>hh13T7=`l7Q=kGnrWWI>G5 zFcbe)IexiMf>bAMNKDXJ-wb2%E6#Xr6e^2$kUFV+quzxQ6!kVqHx)suv!Ny9KNA~U- z8FLu-c_{Ilq>&V~{$buI>AWIngZH38y}YPXy(yp$9hEpon$n#6-bwVICR37iETn4) z?s1ZXsX#o5We~34vlu%Y1Q_>=(6%bBh=<5qFyVbPcx*;9tcpI1wzEO)3IIUf>yQ0m z+wi&I(==YRVx;LJ>I~QJ_twk%ggNl z4D-ZAPt}vrPH_6Is=?UeluBqP5@r1xFxi*4s{4?qpB%uhg9Zdqx4vX?{H z_r|kqt!r$7#MIY!-z0H=?Q0eM&3}s+K;*8KKdJ5*+o-dcCh<>b2=c6qrThGPBo84$ zqaZKA!d`ghnv?qluhZf43w$##g&gO5y;HZ+Irr!5j0UFa{zlVu1}aACJAxshtO=6T zE(`T@<0rS#U~VRRtpiPwDu@OX!1@D?r*nzhWCl%vCvy()m7Y)M6he)*Fh zoXz#mR3rfnHyH`L3J@nl6bc6GxNnl^rp>{dqWLswF`y?(>7HY5Wr^wLcWTPX;zO{S z(tJ7uboF_@j26xkB=gt!&uJr~AA2wS1};D&TE*nshJ&qCnRZ~#>h^CF6$Ep_mAr5z zByP>8;fULKJ?)KSs<-m#PtM-V;V5X9V@<&P9m2M6sf<@M@4xcwutsAgAtaR}mRV;Q zVes)yy5|+R`?eKnsW~4WaVx zT6*8Ecj(efzcPSVm8nl3-$gZ}VyQkO1LbM@OS2ZtHaVdDW$aW5Z{H-*qE(#H2_U1f zLfxle@A_N)WLv$IX1&}8(mL>;KE=4GXYL_R_yEz%!&EH-$oq z@@NF>xZ);N1Kk5;#03f4w)LZNwi?HTrC+taX`7n~eK;*WAl76!dAvju)urm|2?4U( zbG?2O^$L4G{Y}+Hd7z{44 z>X@Xsxb`PYwD>2TH3#8Glga940b3%u7MIae^z0deSw3X4l59mzpQCiZ_2b^UMZK8y zgBZy-MveYp{lqBcPo3oCU@%xpgGjh{+ew!i1N0k&oe>lf##KVw7)^bB9&YYMm5jcbg7#-QER$`0MlcO&WQ7xHo~|7cLY|*hx~Bnmww? z)8CaG{i|(Y=BJ@u{Yd>;+p&OubMc^NEhf7A`P|n(P=Iw zzM^TY&;hDv(f`I)$iXw;)fsw}a;EygGV|^O;;_qKUsTm$AzN)ut8x732}#H!^Tn_6 z!`kQGMQxJh;!?g^52=Rua#@9Avp!n1OK+O)M@DHwZaf3Ck_k^&uuY*OY(<|RT|tMj zZLCNr2kL5arUgyH`JZOO1#p#lu`5Bpk2-*wgS?Ex#GAy7x&1o-><(!)HHl66ljL76 z5SAY$A9J4G9(x_&A&1a9Uz@O#`Rn}0MF9)S?y2< zl`1o|($cD9juc=fOJE4XzHxHl23R%7>(6%vmLi4CJcvi=$M3h_t{`}*+Iy+}l{POl zF(I9f9!RZK+B?$BwB(5QW|O72is)BNT5i~JkLYjcWMoi^3JUK(G@n!j6B%Dj{=LhG zL@2Y9N>M+GC`_ViK+&{h3}#69KjfGgT6zhWZ6AsVET1K@ws_{KRfB7(ucikdE5+NP ze)7jLn#TPTOE^R9O!*6LEoQ7f@ z;_&5E>N2wRM)b%2m(bA{$@l&`L6SR^*VCV0ouMRvc4$y+Ydo}#w$H}%_22FsFi?cz zhuvRoYj_)^)j1ffuIs417Y<~YTE^AxK<)HOnG;PbWOAh=y(7l#H{-ESViXPMeP8md z*&ih?fkk%0t7U_68Q} zoxgnl&@T0WnB6r)=B1}m04kz$;d`29c!~W*rwxkjzAJM~n`D`L`WX#&tZeC4Nz8#z zUnYSz2dE%Ui^xy`G14%@rwrscbGposW)BC z0W`QJ`!RrY2qBSa0%(vgLC}ey)qZq#sLcjR0Xlp0cYQX1a`JtfT%)P}TYcPV*#nHn z>6Fj1bv`juIGf-;eqm(Vr~A?2&m1JPsVGMRjH`!0)`u_(sRQ5-M|dnoad+@N zG2f(5=w*SScX#mC-Fcvoh*0_vdw5*ZT`*LMbB09^?dNRt?km;%;LK!rbVO zlG83gJ+lA9!#DF`LN{l0G&uHRehH6#$7E7vkR1)DnP8+ zR_FkS><3G&AMGPQfImN>;pFJ}`7l?nc^fj~MofGSaz}I4wpcn*@Sn7y&EyW2y_J)n zDC-wO2&LChX2zo>L?*)Hz_#^A){SZ5rbHZT^_R7uJ}svHuC=%KUBmi535+Z2D&fnW zZF$dSHLP_8M^r+bJO0fuA0Do$!F1-3I)gSMKf#|NU;s*a{D;$roZg=-{(R{A=>lsW zPt;>^MKjkN=<>QWuhs{NQx-j@xs^!Yg*um%kdu!T@p4>r87{EJ_b-qQGkjFAG@kIe zva*e_y}aoHG$18o4CM-s%1@GM}W-=A@3ZK-yM< zx#(Ye1jlFh-#_LF)y-4+&R2D2PvIsnIm3Zgeh854Q!pxObRr8Lui}Vq1HM^M#~Jdi znu@;sZ?x%_>3*SBcUxfFo{#t9M7cm}qc1782mtWG{-zDCBaKXXnZnmJbucD;n02iL z`sV+u>AM4|eBb{cdxq>iQg%^fW~4WjG9uaIn327eoKyBnLbA!;Qklt?tx#5(iOMD; z$2q_2^!@z$xAQ#D?H<>Cjo0gSp&)u~Q<1<$qqc_6aT9q&9H5^t*yy{ut_kX?$#WM* zIaM|~)W3VjFf3b#GMK9Ln)MbtR>?4W7Qdy@pm}62dASI`QQ_WNny)Wc{B3!M=eB{t z>-R^JLOK~QS^(eR7{*{Il?qW?sVv6GdTUr^=Dt?FIhIOvNiH|iN3ogp+~RupS|tP8 z=AL56^e?km5%Od7#elmL309Q0LDg2SFYPRa5)bY7`R?A;1Fwq-aJw(g+ZtoF)#{D0 zkVprYwCNfPGjo#ipD_d1=x1>%g{OUuuTWzYUZX73^eAtNot08tUUHfd5vXsfi?Is=oddVUEmHSHBq!cXbx?@%T2Dp^#5j;cLvi`(YQ2;@(-)TV&8*` zU$OL0Jv%@*N5|=exP@TTHg?6j-)4_BElh=Us&dA)!*)%lP(q?T(+?o>dnw3F9R~so zRLBEwfrdL;*Z2QQFK!+DrmpQ-4UcbnNyL0kweIvRgSMQf^%K6u=i51@{NB|1uk@uJaG=CTnt*ydwEEk_`m_2Ys1ku8`nie4O+Y+ z=$)9hr5rbu5$;-FSPaWc>; zh5xN`^#RlRp=;eqO9nj8PsLuV%Nr5uxsTK%Rf1bu8s5CwwPk%sVL=r%g|Yf`wqw3w zy_crGx*QpP`-baGf(|2^^Y_ux`3^T2q2dIQ9zPE7XSzh2LtWqKPBTX7A|GQs*{6M@ zvRx*^v;UmyE`DrY|A;y}93s6<<3#C~2tic>?l6h|-d=^lez@G}FvMGI>H+##j^$C& zGr_M*dgU)Rh0)xg;r>LG(20gRf&}iD3CQp$OX8r2X!4JatrKxUvBdM^BAvp;4LZ)0 zAk$mCs{;h-g9Uf}iR>;sx|-zax9S$*bu7pJ6}cZWtcR81YaS%a%*)ezsH?(w&&6(7 zFQ&cy-AsJStKS=;wsY00iw?YQE?ri&GF*_rE`+>5BK6~HE=$^@S-@Iw@6}OdkiN)t z0iHKG_)^8i#cF|p4O_K2IS5PvC3(o^k?N6Q-p_O%9v&G}g6PU!C#Ung;s0b<>I5&I zUAsH9a^SgP!|XhyEKRUS1r@g&oaup_Dxcvu<&>p#n11X%le{*((84r9G;!nIonIe< zk{1?iR-q)pE_0WGQ1H95yT63eq3;GXkoC7lU*osVEVAx1v`w4pW{l&(Qy8wJBu%vap@a_H{mn@^-eEIU!qHm8t z1Z0#U!wkh?KKFN9;q&aqgW*H5pTy^WMW4zr$yn~$Yh!{{96$R-5%}MQZ7BS=R=QT+ z_Js}QAQirFIv!^`=h5r45}Z#%c?N4t{QcclY(0@#-*N9<79d0ja+TyjQ^tIv6ZFvV;$ zyt&@N^Edk4#B&)jy7`ZvU+)VZGbCa_x=KmWwynm;7?& ztp^Qb@qm7%EYqRaY0F(uRbY)7eAHhIj;-4GAH!Ym^tHF5hfnTT)AOlt>x2YHSOslq zblRL>xvwiEUDhRxN!$sEWd1Q#<(Z-nA5ku|k0xO2j2q1?3Y zvc0H9fq`ss3{l3jHDAg4Iby-1nci}%7T)+v+mul4wPt&)Zv9^{gYfrd>i{WCn*#3P z-b}IR$vDa2Cgre{eRSFfll`#woYwde3WbPM2#;yJea7((?V}Gac)#I!W-${QzO9y5f;K=&Op|yyEh$ zktI1f+2g`k;M12JU??y4k*|?cDcUcM_E!+ZzBFGJj!zw#e(ZhLh34~gv(nY5KA292 zg?A;{EH8cFBd6AWv{?ydh^Tc&# zvF~K;hn(BztBW$umE8wf2uFH9q!E5d2ZI>a5*@dDV8QwEwaes%dMaldpD!kkm%TR` z_?oU*nThczFC~>16;7~b{aU6wD6!5xNI5v@Z;>0wQdYXM_{*?dn`aMTv-3hv)$QOrN}m6v`|I`l+N5oF8Di<*G$eo03SF-k zjq-s39v*2tyYOaIM|@*-)%x5 znP9l#tL{MjgX$8J(LO4Q_7!Er{9SEI08-t2w0I>*GH_e}xx?N6=_+5Gd(a{vU4OJL zR59o(BJj8Kwa}xv^d1orU5&w~DCm#>DS#ULL~3FA#6OrY48U?Ab1U8|~khQ$Wm z%vAi$?$^me{6LoHS)Vv)?c5a9`p#mE{T8##jfi*QhmZR|jlUESjTU8AzvQBE8jhP) ziE&D0H-U89FSJ$?9;(Vr%|YcNL-ACC>LHJaICxOhwZM={_SvL-dd7+UVU-GgdbYz9)7QUisdKusCY{rUQ9 zZ6X}lz>5HyC&Wz3M(VvNwEJ9CwsTo09+ED&uaI=%E!|Jiw{)l-G?u7kXx$O}Qb=Ka zDgncm)7k1Cypt{uPG{VaTNNWMd0b^M^Y+Gco#Z?6m==GJha5SE3urwljOwnq#+I87 z9o<=yuPha9*3~xaUmLDV$Ax#y8-y|VmVPN>*lc}7GS5^hgW{?iTr>mSO5 zdlw}x2>S^SSLA{Atp0Z=pnFi=lNDI2aewr`K;q8C6OgJQ+B|D0tl^AE1AICl8Pv8o zcNk}ak6Zy&))B?b%F3CG16D_X>{yE+_ul@wN^hi`5pTQgwORJwo^pM@4c#|YQLH=a z!nMyIT3=mTyN42Yi3bXe+@KhelOHenmZGqx0feCfQf~@{fKbOUdoC8Bj{3V~eyVHq zyqDNNaC_4d?p16m+}nzkUWE5AZhLtcWb2de&wZaAtW$MuEwvLi?;ZnXwtlh2|7n$! znr4&Q0S@b{Mpew(bRE}8A}#j(8LIEx6GXqh_!DH-K3<`}IHTVnxT9)}&=pQ9lI5d1KT47m0~G<8~JUshr+M|M~g-2FfFivnPmEzvzL_{aeGK z@v1m>5JKq&5rf#TM6c;Md`dUQtFk}6b%#wM*T>oX{LQ2R;S>K6#(sSGmZx!+_a2n#TuD8~O)(w=7r6(1XH%4n&r+ z+`QEzZ-A)c#_Rih3D>>{BWz_bJCQ=a6WGr`(7boAY@Yk6;K$=%IO(wUCf)Vj=NV`J zHUZvyQNzlg^>1@XSGMOM3&wDPPjsLQ|J&jjz`Vb!n%}aFk+a|9~=Xww2E!1HjB`!{!Fyk+W0Y2 zqkdCVT+bV|_Ud#i4Z zzOM4#@RnWQPi!cbAE#7J+_GU5?tNoBu7Q|E=wG#{o7MHc2j_K z{aVjx&92nie9SR^n+SGQ;*D)jdySJ{qoYy(>G$1R+>(F${VhwvH9J|%bum}H2(>hB zV-u5-5l}8=WJMPWPyEVvklSj>dDHrNVI!GRO|G#L&DE-zEUT!fC^zB96p5`2by4!@kV?z)|HuOXEtUjH_h#sk9z#xo`QPoD{`i z6XI+JD0tC-*W@+2j$+LJe-58xbbd%=Xea3aKk)EMq+c(9<(7OEy2y7X2u_9p8?8B?xY6x_wV0)$@(MTWeswM?#u0po)f8!@~7deTPyQ(Hx!dWAX`HZDl`BcMyl@D-3HiO7&t zzk?d(*NGzLJZfVsOYWPE;98USL4l5nnOPDA=CS_x20@-@V($=fz9;y1@aA;E-Qfx zz{Uw9F{!q0)B1&+XiZvV3G?_Vb9FJ4O3%BCL?|Jbm z(VxqV%urcfvrEQ|L!Nt;=gmRdbN&xau!Cw^3iNbEaBB#NF8L4ET-7-My}B)^ zRhT7AbWJd8u{ZAwDVacg`yT>{<^L$pvtdM?&F@B{Ip_vYw2H&oL#BdMX4@`ay!d;b z7)BbW-CV#8V~}x?z8U!zU|UYpz#1jd#LosEeRZN+ii**0>;u}+=^Ha72XWU zqS^hkU2T_H(TVi}tTD%|=K%8ZRo&v{;G3WON9TjF0o)IFKM~%9oHy%O_SW}AUDI#$kGp2`MEn8GcGuU) zp?;Wl3;!GAMY}03XU}Df9rPuJgso~A!?-D`rKaYYqN5EUJwa8jhEmzzsNDGSYmstZ zhOUJTRFWbB*`J>*l%&P|QQJvB)U`XTj#5yR+1SB$g9UuSHRNJ-qoyW^5p8 zty;PIjrxd;j4YhVqx0ldj~)I9wd12~G%hf&08k{)*EO^b%~7cNKmuhVy?_h#^(Q2! zPl{!}R5DmHICfh%h+b@{YcUD1#5MuAXZET7hzP3x(lA7I27zY@>4&n~)>ZQR-6IUy zWHhYo{bVTH`JFiu%)oHLdI@75x(=R&}sO^=f#EJA7xI!-E&Fv33pU`ySjMYLZGb6 z1g5c3u^e<&WfwL8_7d@(!npZlH(;7hGp5qb35gVzO0lW_wy+r_D<>lag@77}OxnJV zM1WeS8ozbYi7c-^(N)S9v}g7{nMV+US=m#7`9Mjq9z}fSLYc1us&Kv|l%cA4eFqJt zfR)aOv~$>*$^;mIJ(yOic*G&$&^vquWik+_{CZ}%0pQ|g+%_|W4;=~*>LF;_VX95+$p&iyEYVC)f zD%!*WbHnZ3+iFMr`cw5tw5+Tw?rv8!%ku$7hj+`!rSkh9hkG9LyZth7j(<^V1Myih zfpF=@q;8I+kAR>Ici{Jg@GE-y`Zj<3Tu79;MAPvVbu$+%D=I3g6;7x}ibGPG>5hBo zRwN0OtdzCJ|AdKcC>@an9qePg^1f9~10hrx96vF{N5kPa!=0AkVy-ueqhI@4oh_T+tIEMUR7K$>L)VAESq z2@u_LWF9bomz{_zMf}lcrDIy;*cH6&EZyAJv%?r>Yi}g0>24kdX_65skdjc)$!wDs z&tjMa9^t_nRRgl>%5I&@q)i4kGd#&FlVPDR;F6{7A4~Rl1teFmSa0;rzQ`-~=$zhQ zJg43xj4f~C1JFU(hjdcqi0`ouLec(6&k40L(|p3wx)npzeFSP|wcgZI)6=g4cO3Fq zHh@jwOo_I&738K<7Fnky4^dsX9vpjy4hk!{0*1$hxuR{7q`1izS_<;DKWe?Q61W-r zh0dr$SdB4h47p>&skVah@&OO>mA@E9YX6`TFFJ7CXm7cLDw8EZk0wA*O8`qpv%DV~ zFQeU53euxre#J@g`XY^rSrxgn{%4xDuVq7uH5`icMU(>Tm_67dL1NM zHQg>tv175Nxzo4!QNk-68XKUXviP5_upt)g!Wq}O5Ab^0L0Y#rT6WAmM0-gBRW;;19mmh0OI>ZNpp=|!| z)T>Uy*y52Fo`3PSEf=^GmuK7{|1~gvk~)k4W!+DOvFM7SNR!& z+2Og*O$6+NVC_*A((3H!xZSNIkNq4iqc2#{Je}m{jJtD9w8U=nKB577^1amV$bt4H z-fUYq92=i(Dd7JtLGIz&XU*WLCerzK^ZFC4XrXC>vWXQxLk=&uk!drVLsg&!<#}b+ z80E>&6A`G@2W0#O*hi(>+|0E6oeSBn##jq2#Z0%|qvIo*`F4-gqg?&>s@;oQj>ZQZ zSLG216;5NA3|$vWx7C+rG&Tglp)EBb1~GLX=U8}s;fIe;vw#$E+yJhUVVN8VsB#h= z&@p=&OU*UbsIsCpUmY01DivOJA@P7qux<>X=XQS_*d5||5N9mOoQuf9sTA$Mwp)(O zIwymob#YIuf$p%U#xIpuSlI2`LCH?9fB?|`_FExk8TM96woDv&UC9fQp@fv=pn7D) zh17uxSjZ}qW{&XQ=Oi8+%;yI+I)YW~;FYcZxFG^+=%w*IV1an@@GLtb~yL`m4m}> z*SkF{;9vmn4y1c715xFdveeBxRfv#o)@q`@B|^sR#Ca-gC_7+EsK9f(h!O*rTN86x zXG>=h%G8PIPLblP3#r{xx6^1o`wu_=@#6<)nOvKci7!$M0e}|eCkkqItE8Bmj(Gg8 zHTL+2P`Q?Vliw&9QPDfm`0S%(6g~s5%xHR{F=67bP7RZ|lHx0|RLvN->-qh)lmb7> z0ju^b0_hO(MnV~uFMrDHNIi4tp(tJJ`XQ#`y7C5C`j%h! zweb~;yJ%76o5_*CZ(UlP{!|~B8uYY@h7@y9I~K}RVWXCR&wrN|=Xs%j`dNT=`kAF> z;C6MnE#Q4`|GjwoZ-{&_MG+<+eZ%xaw-5PmCBW3DwgE6GeS|~ss)L#7)d(s&_1wbT z_NrWSrefNIS2coRx?`5Z7119S`)_kvvnQ|a))CT^!bI#j?Q_Nvv&W-X*67XBD(bli z97mm#_^J@Mo%=@@7k%yAtc&;(lUi%fMdVx`|5SP7%}+&rM88o6ck@>0o$I&Mqk`7T z4toRB)ZR1njE8QiPz7HepN1M(&R)v{jhdafw`Ti4@%QTRv+CHGqeE#OhoTn1VeNo` za_;w*L~irHMgHH8+SwUGwfh^C4ZLdE!=Of;dg;MX@*=V_T-=? zDuQo*d91)F?A5PR%TKTt z*c)qzDn_?PTzRb2!uLjLSLAT|hr{W?K_3U0p`dM}4_8ZM7N(VMrw0P6*)7>wkTx%^ z%5G|uE;@zUN(*MFJa5WNBYG9jPbGXo!p0K;JO;PhN5o9BXFj9XmX5d zXiuEr0n{i2WNfPkt?BaxjrCB27r4?K$NP*Xj#}}n( zkJ=CdL;DiGC{^tlA0>LZ+IfR6X@&fxw}8h8EYM`{7Nbzpx_$yvGemW$;Bm_TqR6n& zx=`D~hK@1z^A*=9$5an(;R0-dmmsa}DXRcQN&%grMj`=^fJ%@pc$*2>3d1bzn-VvK z;B21@-N1ex6@8@Y>UN>y7P=8o;AEfS6VE7HA>r_sXhL$DJe-0=b6TfpYg6{mWG7RhbOI$&c9CQJ=8{>6_)|N;~+BV)2`*6d|f2w3KMF;>1`)tmT2AM&cY zqT6W)mZuW#-gd&AXB*m+8&^OVe@e<%U3edG>HS9uTUuLtI<+qy_|DwRj>;J}u$P5c z1|gL99y>ey;zY#p;NqVr%((X&56foHsc0AIkEh$mq988;D(W}8u9T+CQV74g`Hepz zmSKUSl+|$nX77^9HNS>)Oc>#r?nDUo<)>-rhdjUh#4pw6 zmu|K|^AvyQ=MDqmOt=Bt8;Z7(=)}NLc~vc;L{*u7_6}q1{ZYH81!n=)cj?)$upRUayV`K6z{01cfE`? zg>=Ev&|lU|U+hfLc()mCp+tj1-E2405Tfv{o1{5<-a4e#SBlcOl||3cGkUT^?aRym z3XMa$B+d->SEkwVg#pJnObsuQ$d`;n;(AN%yo98r?JF!a(#~@(Xzzw9R8wl=abxc3 zkk^p!HcEGWfm$^sH%?pAh~Z`?(i?MH?Evr$W?uZvEuhIaPW{%wgqJJ4oM@6XeAt@oey8(q^9b=Nx0`1+>YfRt=_ zmdYa+$+V3eaYVzQTOPZC9DH|we_+tvLWY$~xaI-f27;&*OPBfk4>I%ccW*5s9*+~e zb#R~WWMG>?#;D$R0Y3-ku5#&_dHDHWB1+0-b0U{vJ5>rbN>p%c;-u|X-OG+_whkPC zm$s~EI;|+T@+*I*eDfLAHedfBrF7ZAlWb0U+co2|>E%%yi}AqxV(t08_dm9wx{hXyAF9S|BK*lM};r>IW*AME4X3ewiIej>5W_FVecJ{*K$B(^# z3}xQ?Wla?B`I6+R@7q0v9wNP0Mxyp^U)b#Ts94e7CLc$hw3vBsTRl%tfAH5pSGh|^ zzGJtQsORtLj5i_MuFMT$8qUw{_sHS5xw$Inx7V;CZF5dgns$|)mFbYDzetD7{h?Gt z5nf0QKZRXWRtx6}(bDRO`jyl?bM%63*r)shL{_=OJZX25je$I%gtDUl4${Oc4R3=xq%{aQjP ztHXP0NNcGcdQW7dGU9{^T>N@4JZE-yj*@64q3=c7u^~s(pS}Ik1AMS_HkPCN#7RuK zZsk2sJ`i3pw{N<^oB|69q|Z;wBRz z9_`sI!g!&42pGEGUdLn?zo4u?x0KJ0c-(e-Qd-cWK**39#ylX?Igqv@$WBPDYXV(6 zf9&4t=FO{g-iXcseW__9A;VVU>?zqwh5izH{O@OlP4w##la>dz5ef)UQ1yzfWuycO zu%Dn)NOBnaSK3)Pv)DFU0BQM$C!aRaVJf+0+YsF?}%i+SX zKm6uAoVkQ!O0^5Fr%4o}5^3NOX0iiU8O{nlhgN&|T?3TaVz2T5`(A0%vi^$J2Mdyn zXMF!XdU+2)K;RvJ=ktQwV{l8ty0TH1RuKSM7e;p(3B9&7&q>M=k}^dxs{!j8So611 ziP#`2f*WpFiq{`uj3P}Yc6ccAv59BM3yJj9I4tFo{C4?N(MY5L7=CY)c3&8tQ)40$ zRaet|&_ox5Fm!m6urq9y$awx|X*r{Mo73Y|(7#>q{KheR_h`^MLMc!ad_qkDaUC%! z!6XrR_(hF~AfASh`FlAPX<)r22kRG(de2Q1AUbWu$Vs*9@SxAr>Zhkhpvd0ZLBXwd zSD>AD%z1*zws+!!ttFMc)zD{uA(br`W5m6Je00G`M<-j2vi)qdG_bH}=DS1FV@Hz=>7@ko1KBT3B@acl1u7 zkWU)=4B`0u`6KxSl)Ohak+299(tAJxI%BR0ODvJ8(}K*2NUz;D z#HPFzI-j|L-LPBV>qF5@a@%*}0RLXQF2U*Tu>ujae#S5zHlv~m1rsP-iKJA1t zKx>1L|NC}BTn7#+gISp@O~N^tu8JWaB=Qzn1AB>wW^0N-E+9Af;xwy5LV%Rt0JU#E z{XT{K4-Qj%H3uLX1-)6kAP7y$Daa>R`1$c3vY^D$)gvb$;6d?NzYibt->H4T!0XG? zMqA%at5w%JRDSLHKw%ZlM3~qIzmZs8<@;d_HY7E)ONjhMj;F=D_v&B1>!BV^0R`z1 z(Tb1{u^gk~|D+Of%JSzOrop1Me0<(nrLa0IHNeZSB96zOvY_7-e`>G!eQ9q0IAeMz z{Js9zO?yB@8JZP?V8jtYcAF~wJfU&Yh17S^@BbUqY>QS7Fu|g`d~r=0#qO(JQk5Ra z5Tp8~1YcW(m-{PC^Vt5Rk>(A}4}{{s(C;*^Pmy5_`h7pQ0)ogS9Dox|28kst(WUs} z*SUsAo23Ukndo6qhd|w*Lq1+n&eU-)s6FVpkn)_(rZn*FAFY70JSM#R6_N{I3T3Vd zbQaFj&a7?1| zRl0a2l3YGj0sMr#_hhmtL5vBrHE>_5{NDNwDMAtl!CXfQ^@oWk5O~X(&m+FfG5;IZ z3L#BR1BHRhL@n!P(M&so;$;S1vp^ZLyz%8oXiYqw=#uVI#%-;MiHUO;khEMHR#y6i_W|@$j!$!!RA+u~jaRE`GVkX+rddqQZmW%Ll$(kk~Y^olj z{66cha|oBnw$s6tS$$gC7qXJAqXB`{^n@)2wAjJH!JS6NftsPm&*@|wieC;M-adGE z0I5d7wuD<(6ZTSHFJ0WSn#r<(1K4d&fqC{JKWX1_cGZ#OF;J|-%vP56=YhET>BGC; zM_DiRm<}Gc=yS8vw%(8^QpCr_U4!e1Hqrk6NW$`ZW%0u=fsF+`+UKH(8Jdqdaqemg zbej`YpJS`>e1DVY8RDbx5L)N;{u|qKc?4W$Wzi)@mTo7NR@=e#9CJoa&TqDYf@j9d4{~02``%vG@%)ZI(^)hQ0yh{*m4C=JfBF=a?KniWMfdulk8pQ<;OZcm7l4_6n_OmiB znx8dU-}=pEK2dfv*pIQZg`+Bk6+9dATAvEoX$Nk#;owpeQYxS2fc7~N2Ek_YbN zju$<=q6wA1(WE)2@94(A$A76SE|Gh4GKoE4xs^J@wDlA6cHZopE{dPdE#r= zCer+L4e~9{9BPXUb!ZfjiTvr=4{$8M>9mV;ygr*IJib%=$l&m+}yn&E;@}~lzo#b0jyg#5D4l9FO$G_5~3lL6nMo# z1Io(2T2LiPEq9e4zDY6$zDq{8?7I}7V12vyd#Rh*R;CzBUX?BX*J6`VeC~HU{l57K z55E@1yAh6=ggipvoPvJg2@w~fquEC+-4?Hma-VnrtoT*_MJJ8G2^CLcw^oMW=pWy= zL&oQtE347s+1Rs>pnq2HGZM(S?~Ti>DI*GcFV5@Qds`N1kH5-48#eXa@7x#}D~!R-+F5`MNLvij-yp#%QOsBB|~J;yElDVWWhemodz(WN7# zFEC8CU?#k-YKI+=XcvKrgH$2$((Yn=p z?S7`$^6&zl&=B{9#J^=Zq{E=q1!PS3>5uzAkMT6d#*%*VEqs*=(AremS|5k%i+Q#N zj^u0loK^Yf5}v7*Rr*^A>+${25nb8~@%AQXacXCH;5?`#H77Siw|T^{>!H#;vxyRm zsXL5XehI1%kc{7|A@qQZ-CRv44PGD9~*P90O6424kyg>&s^`CVG@zqPY5o$+xy4 z9R6ww?5eL;zJpX9bZrYQxBqf@jN2f&i%}Oz_%)uM7OUhE{>oNeC=*+v-*FQ(jHO}= z=voO-Dv|@Ln?0qt#pgoPU)rT->@37w#}3oa;s~LQgL|*Ds|EBpyrE@6(FJ^`g-UWQ zb4T{yCex|i@v<&gK#h$fe5;g%Uv=>Ua#n9(wfoMc&trvC1cZ`UL%xve^uJGa^xXH) zB&Zo-_}WR`T<^AR?38Cj`>MNSIz_0q$#*S z{*=+=HRA9?>TDl}P5c+RoUGqi9+G{+#qqMd#Z4@^k-C*tBz$USrvBBZmAWA*H&%lB zFzq5Gg*D`;xk4FIe3CcC!@A3GLAH6f!n>rz4uRqvFK6t%y{rVYRTDKrr38{p!_eA= zm?>wwEZfyZhW8_F9`WDFmx(QtOzeapB9=NSexl#*f+|HdvyfV^@i=)45Csyld4?WxAa*1vC*zfkK+=vAPdw2HtN86UYu1J)ni4L^R@K0D3ZK3dmpBOA0GhamKEf zsYD@DMzA)oqW=9p5fmuN^sEcNscX+&ZPt>i;lEpG(p@Owq`e?%mR8=g>$oL%63k1t zCYTpc+ft8obgGO`_4|3t!|%FErI=)BHXm(P==K!{S+@@%)20=D^z+p%QG|Ayvfc=x z2L@HEgw~YY^d5*R0NI3bchB1rlR~J5@X5)|yT)*8ZuQOvfw^Gk`e!b3e_^JZ=#~rh zbEwRLYJ3+}Ia$a7{nnaE@H11fvNY;A(wLTu|K9J5L;^-4Ea_PWa^uNj9*y@_bM&dC z19{Fl&D!6JxVLY~w$`589r|~m&@qHwd@id9132ZJqDvKb{v(d0ZlRy8kZA7-(qZs1 zd&&2CZ?sjUY%a0o?Z21%BYqylI9tQh74G3ewS>;x!}C7qP26rl$I%gbjwBra{weaPuurk|uerxPbX2;!-` zbNhCXKM3J`vFG{U<2fX11OZR9P+}lw>#J&_G3Dw~{z$mo11m8aE;_#h-juKa%Tdri zJKJ`urwWh|K4o(4LH{;TE*)~-d93&)G&Wbz@AIst`cA3$-`wL7;@zc4jHt^s$IXwm zck*&|rz}-lu8S^#@J>)6fJWX^Lz~=1SU!S%cL%Tvno-@Ho@<`1RrK(3hM6e_b-ACK z&3tg0NA%Ln*poA^Jetp21}C61JfVR#>W}7&OhTJw`L9GP(4*N>q~INmnu-f~9Jfld z2-|0kz@GVtA(hL4*c)a_o5luQGe4_c&wV?Bf&=uGoOyd+aA2J9czq`kKUA$f(+ZWT ztyL$UdS!=Sn|FMI7}2eA+-^4-9eFabN9WQ%A@X+P2BkpO>dH#}i+k8LdcxRG0nJ4- zj|Evz)X$>h@&;%ASG##=jMuGl&&t-JyL~H~*};v|R`#Eg-Ycs?gKcRD#(&Cv^GSTD z%>2Wfa57R9AZ%0zLjz#EYnIxGs)QWod{_g!!P6M(czWJbpU@>|qrxh#m*3Sr zwHC|w;Xcqq7q;Y+3opT@(kJ{s4W>ep9#m(+RD|bJgG42(R0rn?K7Ih%um(VqnW>YV)AkIIaiX5L&{zKO zn&`cY+D(;E<>7%37KaJ`d)pe~BMq5AfuBBq{gHDkzlJ;Nv-k6p#y>1>HNbS8r+{(& zh^Z#2Ciy?--Hp^kMTxjFjPVF(dh6EKKJd(o%4cY8dH*?FC<<#~usTf8RTYu%_|+_B z5&}*Cgi~+go(t~>GX4K|(ZB(_4uai9`hC*PFQk4la>7CYx*7l7L1iW!rtA>h(_PRI zQZyg`!$%`fIQ4Lfe@6i{S>~gkR}A=D|qZGBivE`FsULMAw>o znOa1U#G0Vk2*8FS0APM{`V-C`j>>@M-_$^UV}WJ?moJgEid6DXIEv)si7_VXk(1Ed zCh;?u4LAadtB45joOToRb(qyN&J@IY^w0C=`A6Xlf?Lpj+S-EvmgofC1)&KdsRZEF zt80&fE~k%%)E&z6oxhYPV_60M<8B>=S^Wu&y1_f{ktp`=3h1RI06U67bjcX8MPkW6 zvdx@2F{gpZJS~!iOqrgM(N**ZDcTMZnDul8QcwqBRL>xtlSd+&BvPL?Zl6umKQ}7l z8U?7z@v*Ttrp=<^D0azqI?SLr4Z>aFG<6<%f_NCG80gU*39nT!slO8~2r|?-4V&ktZ$sP=F53Mazn}f|X$x0l-(OF|*C7=fhoWc=BQ$`yl&tlKNnKCDrlinmETWU}mrBn07`=lN%LH&f0zZ-37_XN(Cm6-->5VV`Gih%ly? zF(xn1_x%-qU&e0@8u-80G-gDyF-@BIzSn7LOwPH+j34j+UNOvEG?ojn>3Unb7&GvW zFjI%`T#8?Ozq2!j>wePE2lqRF?z<|g>YbNI-8iyoRQI}R5wU0Fl(+vfAN5QXYQ57n zWyGZR$^EWAsd7@UGh_1quMaPFjJ|ww*DAlh);aduzUM@p^>W8p_y0rBPcY6ersKf~Mu{C{5SQYH7D?lEgWygtskzeSb)jzj0zuBkuPG{~6P zcwWDgx9+?p%lTiM%f1aWZ#`$sOLrJE`Z9V(Lk5^%^Wm0p zH$2leq4)Fc6R#iN{OT#IZamTXc1*6*vhKefW7dOJV8PGEJbf1U-y2dZY+}U|% zp9C}GhJ+J7?U(fJ!hy-of&r;3)(%UbvUz0Yw68{G|M>mb9B1)>yhZ2bB_wCX-bT+> zLeZ6*jrp8wOn!D&pF}hBhQx~V2P98fJSfGPbZz3;oVbYc>xO0Kd_6jQ?T)ckoqI03 zbhGz^_!!!PV zzI{?J&Y$-4_-to%)4{ZJ^WVmNv77y$#%$#K{7uH(b-potWaC`(`)yUth7lR}Ys}~O zOP|5{-+wc@hSRv(a~!mCo-xzTHfB>Z&ZqCX|EvGz)QUR4OM@zg^9*C&;>(+}_Q7v| z(e?v#`=0A0SDw%L%cdGrqp~rO&a58>9o2@V#$27P^R;c|e6EqbeRTH5@1WVP3E9qr zS9U)bX1?IqCDV*aMVu-|(iZqYeOR&*Nz3t_ZOpct-TQ}sGdgGN_hWPCasJ0G>pr>6 zn6Ei@&6mbZe};R$$-Uv5P5id#C1cL2WY6C?BFB6+%0wVm!O?^i-I|XBYf$8=trFo4e=pE`B>0Zd^T+$M+^c`p4ZkE>O?-{$V)7q=2d* z52S($ptzfqQ##Q3LMDK&hu>cB8f_kG71iwimQgQ0a8>1Z@4u?@>%GseG@LWDKv+>% z_|H`dW?b{Q@R_~RI)22XG-J{%Kej|;YUo~mJ%4$8O!9{;o8 z{<+TbK^fChqRVOgL_^U={RldiuXe6tzzJ`3t@6z~-D4eewu9bv?!KbR6P%M0QI5Zd zY^mn#T=D7f-1ocHd13pgEaw~gzXSRFZfve|$HfiNbzM3db6qQA8eeEkVto$I0OE;4 zSA2#0)BlR}{X>oY6zq*`#tR+d<|kDycT(W-d}l*lnfdXt73aS5dZ*@pe>*1A9s8Y( z{hFaCIwz()$pOpIjZ1zuX6{$Uyg$#F`~PFi1uZ!-7v%Hy<5x$UDZSzheIIH2K9l=@ z*fZh#=CvZ*a;(NtZTABCxU+G+wC8_VHmI)i%^2CE9OozW^5l-qoJ!?qGC1qN8n7D3 zwtbFnoVnGQQMW zgx6lVF7dV3IwigKM(5<$-tLn0>T~UCPks8DrhC7HhK1eWAvFR^LSDv;Ikr?B)_|A9l=HH8jn^jyTu?2Yv2r zyE)VOdbD8qKG*r`=IZ+!SGyMlw+1`17Oc_deZKzN`^&!0`3}3+iThX42)x-f)~p|y zW~TLyzaP8p%;}ft%=6!W>EKlEpXRI{n(nL{p6+bq{$IHF&vLNiF5ju|Kirb*tQ(fI zwNcfk7tv2>wG9=KR4|5whp>P24!wGxk$@CeNSMC*{6XL(^|xJ1pb&4Z|~U$9KC8yZD)vxt;X= zo6*&rF&EZ)l1s(gYx?$>WwQu-yo$DYX)t%EF&*02W2f~>H1Bqg56546L4C(>ahB2d z7p_fwjbk!1Vtjp~kLH~&spYrboc+4=o#g$eaq^`aIt?@1KEScj_L{!EPCpl{de%n+ z+lB?ZjOh<;eB0x#W6gqr$%el7V2{5C-!bkpd#9{BIimtThEEr79Fb+vyH)Wewn*l_ zr|-Yu6Fk|bl~W;n+&vr@V0SR@<}qY##rr;4%)dYM=#PFX*yy*J*FU-LqJb$p#CP~l z&eFkYzx6pgx;sa?KEbcI<(iGdvs&X%>=WN<>_z*|r;dWAk7CR7?=O8TAY+rxW9+{z z&;@G4+zrOG!&fNS=JroE@LnbQ{*jmO9&erSFh?cVPA+d8rxQL=_5-f1@sIubVRg}Y zFX;Xn|2o{ZDk`qJF}3R&GvR7velGIJ|E(_4{Xd$7X}}{W*rxVOGOLHB8TvlT%A9!q zqn;_B##JhJqK?D|jA!f@*uKjjd$V(Mbl#_2a{+z&=wp0B@bM?ce72l^|Kig{y1&(j z%aH%4n-|=<;FEk#eBMUL+^>>3>6>MP(|5G28+8Rom0LeN>&$P)c5cr&y0BzoQJEKHAB-Mvib&Dci!%jGC8(lxf{$(-crrE;)Hv+uf|^YpL7SOqwBP-29uG$4bXZ6 zeHRZ1EA|*O@<#p{atv19n1NrF9zow<*4TfEe*@1S_+n)CFYw*LAE5iL3G%Z}b+TjM z<(i5CbpPk*Z@&EVagC6{{V@WT55$MBzQ#Y&jY(s7==N6UWP^P%UyRJ^jc<35dt>9J zYYX(vuZ;aO*R*!R%Pr%2ibI0kAMrDvM{)021^3q&OrL8^68eFUAvVQr+`m5U_&(pp z`}yvt@p;ZKpxbF^pZnSEQ$9>dWZa3jEFbFrvld}`i@ZN$uN*}b{Qj}m z&Pmt)!q{&fQO~KH`m+Dt_WsXF|HcC3%k}Zl*>7J<-+wE(zvwU%{S?dKU&>*@x0ORO z%n!HZ82swd*3L=hej1nO{CaCHa@S}_RQbh!6pjO5M5ngB#+aV1jTzkDm=`7>8(Ru= zksX8g@kM1%)XdNubN}0RjLDq{?>+y+EqTxGo{;^(?HBj!2jA^1xWC3=6YW`t z&$|wtyPiI8-tUtQs|$7iH9r~i!gHV3@)ph7=B4?#jwU)@Fbx7;4hYXE0l3*G6x9?%FDw zpS-5ZqU#z~yqQx^1RA3fZ-3pDYhLe^lD%YL`t!>NXRKX0By-E^8#A}98Je|q<&dlm zlRKtPN{J3XoeQHkOTInbKFa*BT~q?Op5?E0j&@$}TE%&be9yZ*Vw?}iIXvAiW^sI_ zu%=vDbS}|`LNw0gItH834ST&``4DR}t=j=#S2Fg(P8>J~D1P+Dv7i5iKl43(nnPdigcKS((Iy!LT7e+=r@L{`?hmlY8=^U@zDz7xR@2O5|^o#yB+8Ey3ezjeB zJ&RAF$IyFVU**yy`h+$(u4d8oCsZb(@>r%&qI&-$jVo!2jI?fm}fYv1cu|ChOa>!NcCkG=8} zzrZ{HxvG^D7B++Ssm+1y_vT}4Zc@3yxj!4zX98`PtXBc~vWT4}Pa0=F>>1aCu|GJy zw{Psp7dzzcoOuHZ^0wqr+}y3rUn+m9e9ESg!XlL*aF>lFzFi;`@An z+v=48Zyyv_KQxK9p9;_i$qy6YnuN>PP{h~jSgAp8*TX3H56YW;Mz7b=;yYSd+ z&LUOqKR_<=Z^r&_#{O^3f5ZEKlfVBP-v1lk`5Ry7??8Jcd#fD7_EEX}x}WYA24n=@ zyWsmWJ}yn9{R@KAKs@xI2qwR&(8kys_$L9mU99;AY8+g?w`2cSm!!G(UJ*CslN(an zl9O!t`H=LM)IYRbJv5``+F_Y3*AC5WK`p{vwEu6{USYGOb2RopkI!*t-*ED8dGR%5 z3zG`nxX>5Out5aO$_InDUp(|Y@vCc_3!V9Hw|L_`lxybnO?pUt@6H+c=ZloPey@Ad z;>4(OjX6p7ywKLXW+mf1Q`2l7ku}!qXKL(~WBG;l-+4)^Uzt0+ffM2LqP9K!^K;2G zZ4J~#;-kq^IN;=9SD@WyPQRp<%r)&}&cU7U@y$1-I`jLd{&@KbmAZ4D?4U>f+#I-h z`kJI@qc;{RBX;yAE_TruS{Z4n%W1iV%OdEW& zqT(U7J(FuMd%ke{tt@N&UwM^-s)0kc8NHLRV~G*y%$Eao=4$lKYaNrOa?+U~pNHI7 z>#Y;W5odOVcMd2QuY9=1-j@^4b{@H^jZ-dc3hi&#k#j@EbF!)U2KUv2e>VE;b1)h_ zr7A+;dc;g<-|vX%gq453n0bo`UF{otYPcYV6wjTv_x$3}sm z8{elNMT_DcF5eY2;}*;R&fVlHs~DC(xn^y|~oiBncuAK#As&*O8QH8-B* z)J&R4`I2$PC-n;LM#l&G6IPDrEJO#?;oL(01K;zvdHqwE zGfSzA+Zst>7>2R?V z88MyqucckDePGP9@b)CG@jQ6(1!9;tnLomKEywPzXWXq%B>&Fqmwt|7eQ^-C)YymV z-odsvIww+Jm99Cjv3BgmJJ>6C?rBD^)FsqGT*$dDA3M$!(wpP#*jt@x^Wy!_(Oj5( zeLElJos+V8;J|q?Zy&4-%z-wSAENt$^}#=9*M#=}X2;&0yTVskF(l*17AHn^=R932 z>>2bXHE`1k$t`0q-r06jZRfPCM>O_8?Y3=KU%YL%d34Ps2)6w?Z}on1t4iA?@a62$cd@= zK5fwWdUMaA+V7ncTKzY4+s3@JG)XI4*ciqjEd z6Scn$?fGAp#?I;qURPPF#Q>dc>+i~4R%4d7b#ZEM^`51 z+{QGGHCu1a)ZBGn)#$kKt>P;nAM>+5>5Jd&lrq_sTfeU$TPxoAd0bVeLxa&YOg2+v zZ`Sx&ukO6A=)m!|57I-zXIqUqqb&!?ZwRUT3ty8fjd0uF0Djr28Y5q9 z>udk!ky*Re56k*CaO}l9>xR~MY9&3#ebpAVU0Bcfhid;@+J~Dhsn7aEdM8*e*{d@(_P;TYJ-Op0*xZ@4+49ce+E1&@7hvt>w11y( z`~zw540hY~eM9;Vx{7z3-6z#hL#TQ0@fv$KHV={)QEoweulDa|{0E*pkhUvUvSVMI z_J7N*S2U*m$ax?Si4Jed?>`X~^4-1d4fdIsMD1@%`+q+qm+Y}un#12UA&;89I?fr{ zk8(eapPj!gPWw6BEV;@z{@(UmUG)Am4k&T^;eE~fr@%X(2gc^s&$MIz8}slVUw^(6 zTk%60Bi>Q_Z6Au_e^(|M*G0Dlw*~6MyYJC{`4GkCe~vZF2Bn*3c&zZwf2DW4xnygv z)PB`Z{B~QebNj_z(SgdZY0Pc=wO9K)bmo%MA@cVvD)Tv(`k`~tqiYM=A4r2&r9+G5 zzj0jtmKz^oz8#ZI&3krR+D~n*ukNJ4&-C&AFXO8^yT<43yYhrPclvn8TLa*>pZKd~ zA1-_bkpC?Z3uK&S%uQYKGv*ff6M^=4EjHk>5gZ=_O1!~0s-MZEMsK0l&$RYRa=U9n zRc9y917yX1_>phy1KY1UswuO{iN6akyyL@^spJxuqT}hC_3487M4x|cOuq|gc!^}! zc68J5Oyj(GvY{Wf@in{CkKxc^G&LY&@Y_a_mmT`un7mFa2d5F+Z-LIX2EyA1FF)zC z?3!TxISAM)lH209+a5XA|KY2E_6O1+RQsi`?D*UEt7iJ`7F@Vgd}CWyyggxt>x-#|jj_#hwVtBYy>_l5gVBoCo)x8O5;@e}%I z6|xMkFK%hf<8i+6u-E@5_VQsiFHw2x%3 zUNGYB9_{At`3Z&YZr;5Deyr_j>o+{NFwMZTi0&VfQ*;-?dkVw5I^e zy`T2FzxIrmyC3JF3{0qHu6oLj(R=mOtq0bxhBX2YRAzS1N4GB z*4gZJFYo=;hB}}+NCp)^S&sn)Zsn`DIk`f+YZ_D--2RjbH?;$;PpUAqSerLxFZC$T&FY3vNAKkev zB%tj2|7Q1SbAQW9xf8FgJblu2(YvYH`jfh$z0?oweZ5P}9%>MFPi!Bv`mz%$+{k^E z=g=HineafhM3LQuebzU{NLVHAfEpcI(yje5LAk^qik+M;>fhWh#d^1Cj%`;p~rS zb0*d_H(wANyy}f2mVt^K6I8Cqeg} z>c8sUsJWuvFs4xdU+EOPg?g(F+*|TlCNkhmugm;QYTYkx%G&PBzZBHNDVDSGkaCaI z^vy#CPUYUxL1ote=dX)4&$O=`ORd>_TdOGAtLDsGGxt8vxwbvI;wbLj07xevaT^?` zqn?TFOde#vt*vlrPb{7v%lhYhruImChI>i}YV37y3qF>9yZ@>v^W*Kw)Cxx5C*BXF zz3RU7oU3Z5ZoMRW5_ggwlpa139iX;{gUYLJ%y`;Md-dN-d+|Peu=ncE#i0Tz_8AGcLG?T=Ac79c&Q2 zr~Iq(AkVj}w}JYEe^Y~l{Pi4a%(VlIxwt23n)X~v^tb?=1?mIINGcGIh%XL14%CL5 z|Fn(@hYuzd)XMtwzG$zyJJk(R2lIFTbKSb%)46Ub^zgl$CO)0jk+|rK0q2fnorw%+ zAE5W3y`A&gF{Y|>)rrIRGuO74j2-j5dM0eyZ^VH=k-PuOn5oN*nfMmZx8F@UFFX=) z3HKmIb9qGLAUa7lLT(9BVdjJDV+{T8j@~<9>$H7x@2UBu_U(~YG4FAqrXT}YKGPT% z132mCJJThXX^K9Fej^HZtYxx z^Jdrh4b+LY<3f_ZI3PV#T%fyOZ$v+Ssh04*vv5HDLiLPR?k(-L_Q^BXT;fEOdz*f% zK1+SK{a3$j-)#+ot!2^k%1@EwIQ4qkCR#~fh+jjF!E2pk%u5}sRD$+XRVQxsp5=Yj z%&Knl@ip|r)C09kfEwh2uZ5R?Z&`N&HJv#Hv=3tQWcRzBrW%2nKz*}w z=c2u(zxr#~b>8vsdq_X4Tex`s-E? z-g`yN79DKeyPQA1T=+k_Ee{KJR@qot1(qFYq7Fxfc*X{JX zIgq^&uLjkLT3h1X9&wGJ{jXlVC)%rSl=>}4^?%f%?Va5x>DN#DCjC0MU-EA1T6R;H zvwP9N)ZL2*rS4ujC~Y@&fV-&!+`R&wSc>MPL_T4AIk>SJh*9u>K1+Tg@HCt zHebB|PM5|`;NIyLgaq%?V#ERY5(H>N0F?gPmXZ)zN18>u585I^Xl`iqF^eJwxH^ ze~vSvaW~rjF%Vttd}rVoc=bRq9?%#FZ!N)X=|HDtBeB1S^1#z=t5EBhZRr1lUb*+t zo|jBFH>i-gK==lHA@5Ue`56tWnPygdvLHm6Ha_`oB ztM=7bH|?Xnw|3fF&!w6u)rQ#m1Y5(NKUW*V-x2NQ^Q<41v;Fj}TAi7jVcz8ZG;|=g ze1^d1IXT#WV(}Gy8Jo`leG8@sz5Ul1Oj(C}g#LAPc2NI~=ToS2jL$*uZE|fMvafn~ zTes|`{SxZNy>*0^_C@kO^e@WaaqCRTmQL%L{t4}r{gyu)WRp14C9^OY`M|NV_3@!( zK=yOdUdHn_PEs5xdw9t9PWO28;&pKm_pl2hYs~0*1xtI?J_n<{t%vi`-dEQo z+S?k5!f{^CA#tAO>*0ZecV3e4IE||dly7lfSq4&PS*1395 zYy61z>c6Eu_U_Mx15u%${cq6w-;m$dqGdt#_mP+jqMqExq!@N-h->k@Q7!7T zYApbZ2BguEWcOQ*t9lg|O992`zizu5ENO)vBo4Cvu;jo#XJc)_qCLj+ z7{tLfbXKShem6W{))>9_t84Q-w6}T>A8+4)bE9wN8V!Mb>!5z+#A}jtn{up&bid2{ z`MJG<=Rkbxl;q&^HFaVl?^yx;FXbGaSj_&@RkJ|-|Ep*oSo|l?=?v!JY9Qo>ud6jR zlhAw1in4i6U6b%S7ikQnPlLu5&d2r4Gi{R+vHkPCw6}4d^qzQMYvhb<(vyxnvz-1n z=XxdW|Mm2r^KD!hOb;&lgWO9Wt{qbU@jvrs7uH^N9M-r>e%RVP$-Sk$)%nwVrL1PX zt=62P@eFFKoX=0uT1;ob`#;&|e{4;%{2iZ_BC=EF&A>ZL{J?1i|e_J|OYkVcQLFfM^ogd8R5$o(~U9Xbl=_C*h3dsO#88D86 zSR*KNU}3$N>pm}SY5xnf|FqAUPEzHU>Hj-R5vL2dR&n~T`(YyoG5*Uquc(+vy5Q3- z=>KM%lK_g`pu5)is!IR2SeqxkXMKIu&$IT^!GB#A_ZSyC3mnp3=fk?D{mGRp;{Qz6 z+JAw0ZQFl6&v4%rZE2t4ohdjqZvXM^T$xfI#RpzU)}XnY-{QEpM$va<-}XP445NOzMRGwLkZ!fm_jn-PAp3|Ozo$30 z@0)_$kR5}-m_Rv^r|#q&@qVFp^8JwQ{A!VA%`nyl9+=Wca$h*kgZBPsKNb#1+s+yu zUAO=~SYdzN`TcCOVsJ)lcz>_WZ`rzFOZ#82dn*Q??BvBiMc-woX2QqO=yI2*PG+x| z)k5E~H(F=!67mJqyMaMle5kD3_Uh*~lK2VSw(784@WXHhE1Mf;s#WRPcz1pu$ z&n(U9n_QW3m|^8UFwWC+!q2r!n#|#ifjaHlL1$V$^Yqn8iTHf;EbRkrp0B<%*Xebp z>XKEHui9Qc1F@NYZSuo?et^%a!B_357q*7in;-J>fbIW6e2V_3)3%Dhr4ReVZVy}; zYj)g{DLxo4n-?s`R}KH1K5450?Vz)?fx+)R3*P@JKfmLj&-3xVYHJnmoZ!??{}26A zo=f$0fi-LTP4Lz}+8WxxT5>NBSYKc{d@`{OomI?VEWY;+4D{AIix<-W-veoH^IPQi zc9HuW#CiF4&~f^+Qg+rLFYOCzbA5H`;{6Z0HF2UVyh;DmUfXwX&0cZ(Z}Vf`{)-Pj z-;6iYzO;SUIB1VodHb2bnyw*or`s#mlqY77EpK(eq3KX2hX=*M&Eg8Z@)(_MYnHvRf!QwIx6|NpE0 zTfOIv@fF`s>5+;Z47-?9vOxGpJ=3ahADy$=*Z;!)j;)LTc63eFXP3{Xx^neV{SP(< zMfKmxfW|@3a=tc=XF`UN@)s{wQCmQ%xANg(JnxdBXQtX=^LRf0(A=i_&swivpP3Wc zg&Wldkt>E|wt@Hed7lBZHc$Q6^B8mcp2_<1^7*8vMEA1j|06FmE!>GVGy>Iue4SF$ zpL6j8(zV8ZHTGNG+@{v%@aK2z^GiI-v5&eX*+J~HiO23ea0u^X^DOPPF60CGKA&i3 z`)>Dva!_0zSc|Rra53HQOFPa7xu8UPpW_Q{`1$6uzA5G5{Wq-KyU%WU^4iL4zT6@C zbxu70lG>F{WStmy?b%>9PkEjn$5nIM)EiB|^&F%6w#|@veMb&!(_d+Z<$i z9rkSFn!;lcs0U>WCcO{oTX0e>P)TQ(c9RYu-f4*5`$4oXG^gXwYisS-R`sF=zIbi& zgOdADwAZs_liFSGlnX2g_@-HqXI_uS$Gs&JF3G>b)g_zP@Phd!{AN zx#6rq^m`r<@2lVW^gm4h)!3O}A6P$x{v+?9`meEljR7kszh8DK0+ecd>Dnao*S~Uk z20rsPFYWVd_sHog#$7ZZV-4$Wf3Lm=`a8DoTSlJfoSgm$ZBtHK`mUt?R~OcT)#&#P zTMy*{s{^!OKx}w+7Y?ooB0;G)*3vh~e{<&Z{_xIecBSdrMt$UIQ(y$zmN~J=+A}^4(-2mfgOXDdwI{#7!E!W$OkUOojJX z>ABH-+7~gG>wN~(eU^^?kGW7j-y9&H-uBz>KP?9Ts|!kzYg_k(um3hC2&M<`f0P3i zLq~&R$3GlfXd|z0?z}8E3|s$%d>&uzUcq{3*4Gb3`&|?AsOPKcG;w`C#XFYwZQsA* zx8n9cnYMAbS^IF|{s*&#&%D5a4S@W?QujZuVmX71>$%kq(7tfpW{AADeRf;4*LuwJ z`<>~eM887A6`!cz>a(~1pB0n-oMxiYXKSAK@sXu}u>QaB3I{89azym!e0RNXe^34Yo##j&YSGpSb8C9kUiH1S{TCgXs4;8i7W6-G45R}j50gayy5NZ9 zU;U?6UC+)|p#N{>*K3EE&vW~qZ}Y11{C+j(>XXLM9>qKId2Ijf7-*kQ|0HfFdo6zT zLcht5;nO~MDf;i{(EYc1@Ttc*Uix2t@DXkO&Z%O48lR*0RSmXl@wsyE)~g4%c|P9H zW6hnD4^~T)sIsEs1VmH!YdH)8dm z;(>edrQPe5XMe;S^Em4N>B{ZcIh{j&T`%ufb$S2YE=_(YA3hz&#moDF{r_|+-MorM zo(k#!#gi5%@EJYc9J=cr2Ajxvhw8u811oo9|2lGB2!E3QlyF1tr30(b{}~0eKQyl` zdoSAS8D{MM-w#~b`d$2e+9SPhpX0RsxBL0b|F*pu4CJ};z6I#X_gnB~Uw)2U*(P!$ z&{g@7qWZ5fSg?|%x#a(fez?I*_HkKFG^ z@9n4mfAjqLpWw%z$7fA$T;P^m+3S$khyT2}7`r z%J(YYeVovomgH}*^JxdzMlNpqlVCnPzX*Nj!8SfyY~{1X7Cvv`_%CMD-wzna=b-iN z_>tJt&l(!@hb+!`S#;BGO9cs)-yL--aJIEf5&K6`pUcW;%yLaytOP!kT zX!p7P?~VO?cDf^{Il6;(zDno{;}SRq$C<6B#fRT4-PNxspxmPDP#lo|5eWUR|HlCI zxO%V9(x{HpLfZ{9l%l1Fi7-&rFIB2|dp7=w&V`YJl$Ni@Mi*D+>`b-|u zT^k~YeIBR^ss%v5o^^MBFF>DKw}`8I^M@X?GvrU`|7`3 z3H7NlXa+6<7lTUzpx=b%peZ;PoD8Z0$&hr(@vZ}H-(|lfyW)G%@;snEsc(V(Cs*yh zz|@Y1#z4nh2y~wKAP-2EB7ykpc;3{$3Kh$nxF$6sPQA8ag-+ehh`g!y*^&S1e_q9h z2RE+x_>jgGpAZH%s`yx+b1FX6{mhE@v}+JK{(>6iZ)82Gt_)&pu5meN0>lFf3}ru$ zcO9UJvjx+44E>>9I|^|W?9-yvq>=w_8C zWyF;0N)N99(gU@D?0{_Q@yh>_ed(vDmpUY0!rDdetskEGHMKCmQwyPY6dhn5za^DrD+IQwi4uS(lV={g zChCGo9V`FDx?v7$XF1T`vFnFkrxAfM`ZwE@yg5!870p!Vx;)&GbG?D}1*0kicG0kZGX-d7Lls|i$n zkm{m7ADp!;DJon#(8|AJq2pTq_uU(Bp6gI~An)fbB>P%FE}!=3e$n15`vvrmbLr1I zD+h0E9Q6eKy$YNUWCP>}9t9(x19t)d9Km_v(GW?4$Dw$$oxa zB=lc0FmpbBfPBGvAO*<&AFuY0rcwWMQ;d1`+Nf6O{=W)j-?w&TX!;kHeY?KW!>y{! zK&i?G$OfJSw~s&sQATch?nswNvc2>WSgK=&WKSguF>>8oPrs^ECGeKgOv{eE}6)(eb*{-4U{ z(>vM&eEtCX$NFr)&+l{BR@2&gT4M`8aL?8CBPVd>D}myG>OgUD1#mpcfpl^Oo^iUh zEM#Bwr%rr#|FdIm;JUK?^8J)kINtc4CI#PK>=b>!OMk!YPe$$)^A*GHyKAb7{#s{j z!o|@qaR=oCE&z&yl?ywb;z03XM5j}tlQ#^{oSrZH_jF z&~?0XQhZ+*Si2|si`R6|P~cz3cH1~A&b-hux+V12TG>VTeT8LT>q^S@Tlv=-bI-Pq zS<1Sy9qG}cYS{L6(_b$I+L2uZ>`TCGUoH^{i1aLkMIF&e7(tal{2-u zAK5+ey>y=!iigA(f?@+f?eg*oU;nvn&B$1Mz{aeV|1dG1m-doZ9oJPj9-X##OSYu}|e0=o$+Lq`ziqZ)Lxv zF`sC!XCgKZ&;I7DoXGxM`x2nshjc&% z6B;Y4C;ky$_YUl3l-^yb=E1LZ$8 z4|End11KI46c057r+|}z#^nSc`4FFY=h4Lv;#Ds%SUaIUmHD=2WSrrh87JZQ?I>={ zS3rNC?zig;zuq}+GwVwCpplXX^*IZOwIyfX<*bxE!eO7kh9Kp9S%UyyDq5t$l>F@LVY_7Mk z-ADGZ{gQpHE3b8Cr(Pfb+m$Cqj;4)Qg0q48Bl#08%Cy~Fuc%F9B^g4;)@N*obYcCW z+a7Is=BHnc%5-zRR`(y0>o11vOZTrIR`ah@vo4#-J9l3+X5iDT)$y1y)w z;yu)@-(gJmF}#y;D5UO#S?CHowx>eyKxKr((p@2YEIHd{{9; zohN&j4kU|`$$w(gm@q!CV1w7K^gUOx$*Gg_4te^z#QMm;Ki?Nh_T8Az)%yjy-_qW< z_Dzn{=jG4MCiZB&hZAB=Q+gZ_v(IkooVF%y&m!3 z@}8G|v`xG$KPVT(0P$>@@GI9TYU3V_7@swash+9PGqoF>b|>G%yBe~Kl>Oqwd_RuM zVXd>;&Z(KVa^1JUEU*OVS>V8RyF_S^-{ zYzZy}O@PMoM34)Tf&41z0_zv>v2>f`G}eh>Z*+}$uw?Y-eKLOAZ|Og?PvT$KpA~fv z*OQGq57Yz+z}r^w!O;RT@lGx1zJ+IO81Icvxv-Va{a1FPmLom1>>p;H&-#7R{Zo6K zk4ukOm}I-f`t? z>9!pc67=qvHqd`xf$aO{_z&^>{4t-G_Ikg>ETDDvAG#{$9a^E7=t3afCB0PzsIBq^ zk0#JFGtfQj_W+kBsnIidZ~0lw>tp-v9B*KaM=|_9>3-3F!bR=4?n~e!Al)zeN!Bg> zwYNIIC>`LXyX=P-%h3t5w)1Y~iMWE-GiDmkD?w8r-=H>-f2jOHMQ}uV;OlV-yg#c- zHEKM*D4>7ne2?t@A@tY#Rr)15uXm1Ln-X2FD{X=fuHLH7J{8Dzsl7)N@Y%cF7)yLG z-+JNaS9_fIcvSP}`3ukSmn7y>zW1Ba)trlJjf7TO>v1Ls)cuwRj)eYdw;+C4MnB&B zoV3a~%)R_7`HB~Tvw`e^e8V_!L_Fa9H`&~Jai#KacaMF=TiD?uSfQS)OQtI z_H&$h{Te#yRsO?urTcY#>3-2#YqVI}2g-kGbb#dnyPx|Ly5L#DfUb;P8z8^nTwr-X zGF56@kP~0d{BUcM-W_y3^gpQgXQ{rcF#T=5ry%B+-FN8^-F@_T->r1;rl!$P(jNIR z(plmG@#|^eRM7A!LDz=Y#*V=Ufd20~Q59ZcJ;MvI{l0h99WLJ!D&~{!|7Ak9^Pd(i zoUpJ-(0VG+I@gj>(L0d-fjkh(4-91kEZwyik7>V@m~!Grn1B33*B(f}o&ywTrvr4K zYg-i;lz994mPEt*a_d9?9WMRt9A7}pr+3LI-=mmMv=^-G>s=#y&&tzn<7S10nNBo9 zJa82dze;a4KgwukOz(fOrV#X>G1nb{13ieL258r~S!Vd!dJC(yW_1*Bh7faaG<6>EKm5BR^f z(b4bqh?`zI*)Mp<(v+Tw-*!Dc>fcw_uQZ{3gG%E%oKk7bb*EGs-LYZRsE(&bjq2n< zXFhj6weqMg!f8HqWiNC)y#U?$-2L=u7d_bbI3s#g&j4J1W~G}it=;UkQ!;KoSU-KV zGx)sryss-e81B_I;tX zY=PuTy0scmtW|1Xz*$sP@6Q__n*PE4K3D#ceZ6Z(@0mge9OV5Yf6nQbyyr8afASvQ zL$PNbm_Hz85AWLB!+VGJuqO2$-o>xJPNdsx?!?>YNc-B`^L^p3ue`JQ6Nd{+0r zdYy896B+vt!F=G={g(cv(E-8aU)K()1Nfixz@!~_LTyo#J#oqT068}5*{O_W$@8hs* z(`$WptF-~!ylDDo z<-ea%^mlpS2x>be_pa`T{$Gr&?VOf%8)N=9m<=@kqNiP-&(c0n{*R^&5I@Y@#XF_% zgx1%9i-COLT%dTM0w}Q#&^hS;|M+VA3g-BWiut^2GCU?_6~058VT0 zU+<={@~?NZi1t4EXSna;wC^O;J1M=gZ`X0wdbVX%+v(CD+RN|v<@}yxyg$&G1FQRk z(Lay}ygDFM9N_f>b#03xZ2-rK2R>L!I!G~r;_dUn382(E;Ky5&4KY9)=)cd__XOtp z17ki*f0y<_b-$JUQt1BD%Dz|k`=99uo$v8K(}TOz2uACSnCq@(g8W3RDQs&JCcVD~c{QE=vKE11>D7zm@_lK(O zEY2Li)%|*(<>nDN-<_MAGKBQyg`}~_w{<1wdV7*F|0ULXBEA5R^I7;GzrRer`y=w+ zbBWEDb%9?VM`x^q56DZ17ed*AlJJ0J-$Lt<|64jhIRWKZ6bmRPWb*-hEdJ&^n5K6~ z5Ae~y7=9mg_u2gdzu#;3L-~D@eJlT7yKim3rF|j#Bm4Qb-~V1>t>a%3yI)jYxAprZ z`&#>VX0MES6~e>XFnSGu?5g5x$&~f2wD*Fq-fcNwdg6=Eu8LhB1npOs4gDqemj3!Y zZ8KuuiO8i42}{t@5jo@6yHAtlP$^+7{GA zA3O&Skk6GKkniV()dAKA)c$aOp!i@h*Bg94H@FJO53CKW4d7$(H|N<5^J0hCXy*Ao z4oQET?>TBQpHJ@>l6|k=e;C<6T&_Pp}n8;>4(Nj2wAocivq-mE!6n z*tT)*EOPZL?}GmO4&?z$f9*@k1Ih`NYsU@L-je`!$gLM*SF+7nT-{9ToiN^;>-YM7 zR`&h*z94qLc(NbN?^C|V>i%O9^I6@$pkL;Oq{`vSvo;2~K=!mm{uIXt*&wa?lp3Cu z|Aqee@4@81r1X~!n28^FQg1GjP-N zXfN-Fy@Lw}`T!+v@4mZJ&D-7L)1d!K*XKV*F`sh1Zobza^C{OG%I6Q6>o37PpQZgb zqjUFmXb}Av1J@pCenq~Y;;AyCKUZ>ng3s~USN~BqIzT+|(92x71yFqeF^KE4uq)Z- zIQN({vLebs|0fH_eExZ!0K4zz`;I}(m!IqP=6g!`EN`If%kN({DC_I&*obkAnqsPx zfqbbF`_i0K#3mgu2OoVEc014)4CV(M&JUCgc%58C1?7Vq1LXmdK&iGpccmI)fVR;8 zuYAAX>i#2$`DF70?S8)BZ{vOMTu&L~dk$CEZT&vUzux!q(zU6J7_^~4{xosFD@U4J zFXK%-@HzAO#i9SAI&om0Y}N%pF-tNi)%N|k6!U(M_-dc@N!(J}n2-5f<@*mX z=ev91fYhIv>-~}U5$|Mf_d9rC2RWY|%=PVHzGnxyevq+)Io%!j{X6jeb}*;AgSc-8 z^L;zW_qpIXt{ueuF39`sKnDoleK{(7Z%Ni~^5%MN%%^vKeLK2_)ANjzkV*IXF2#Pe z0dnfrR#|5L7WUSBTS6x0QZ0ZO$wk7t<2TE|2}|2GTG@mt+L zpm_NnXufy!g;gHAq;|!A9ZrerG^A0L%kIB2?%Y?dOKdo$NAk(jd!^L>7|Z}Od#Aei zwnre&BbTz=jwBxwE9zfq&9e~b4pYAVnv}j-V)65Tfa~5Ghf*M zG^b`#%V`YS4WK!w0m>lu<0^-2qW@du6;_cqJ`x>Z{lEpkW3onY(F;L6kOoSKzZO&Z#!;DH6Qm%IGuW)pDg_Y=lK-x6&3TXxG~jvr)$#U=n7%i(U6OPa;b`^ z6i;hRMZ?l=;-84+gEIP~`wx2Q@6GiG%l8EOebW6uj;rci+h8!`IPe3mD?8c<D~icQY|c|bZA`Dor({IZw!#g=`4zR&lL(_ClW(0bJQ-4FeT zzDK({fO9|&u)NC0GT-R`(z}XJ|DycBr3Z}}a~~}%6Z)&C&XyC+?7m62d*}IVeMdok zm(BMS%<+5Y`KamJKe9>8gIrGjv}8)L33FvfWCO%E;ZL%H{%%6?IG-PZ0) z_NDt@@6^<(RQ`GBKk^-}-4>h$vcb`!zxsguFClLu*)NX#7e#;BfZHDCnoYqG+kdWM ze!nB#d~!o#b8@|Zly;tPCG)$J+9%4UDmE2ws%`R3D}mDLRhRyA`=+-d-@m8Cc0XVC zmG9SkK69xV?!oxI3C6rhyV?NhD&Qhcoa=7R zeHrG%o(bvDe|7LV{=&I_#e81b*Br0r_&&KIWpnk!h@M>ZQgAAeT*<(EfdJ>+4)w{_5%AXwsiPL?8o8CzTQX z4;V9A_WyiP2PA_d+MEq_Opi07!qNXv%`0Zi=d15Xb?5h8`ojb8#UIx-tb7ZNxe~~( zRtIqa|F^{ScV&NAf%t$Ond9GIIM-WP z_KTY1UyTlUutod}w5By^0!{$pPtB`HmP@j^WA{P#)HBijJM-yZ++4qZo=1M4Yx{4_ zac;j@b$+Vx{sjE{RT^^zke)hP^jDwpfA2<4S8ggy|I*rk#d`=_hjCr`E!BYJ>xeh! z{!BCDhNPO%|4Y^N=f`{{sOwga_k-@qi&-o3IvNx#4lGGKHFgop250nv_6IEey?$S? zn9p0^FL|!uda; zwq#?)w>dyD))8;cTUE{DZQ?5O49{DZ{);@%bmZ4{`(r-xyZNC@e$Fi<^6)CeK5NpG~bi&_xWUh*95KOeu|S7`v~Kw=X>;A|09}5 zyA&J^`n$Z#wW9zr&}@9avZ@iCvz5nS+i>0bK)D~;tuoo1iMfX7xP}xJ^U3Z9tL=23 z=Yjs@`~M!%B<6k^M8&G5^cH!lqLPuZY%eqJ$c{mTz}cBvZ25Hfe+`Q@|$sOYg^GTWwZTpXS#fV zv(WuN7n1!!g=;+YOi!rV&UMswyl`#ehqOp>pxSv7C}|w1F%18#Z+Z*z{d-*c7uW6w z$iDpkpUC-Lcgi5f?roswdo+$ugrdJ_Se#AAS-9=xdjt@lM+4=*iGSX?9oplwhm!x| z_yOL0;B(J$UFp?o!1~sFJn~IG;Dzi2ZQn$UWnd0rvj)5M0aG~os-EU$4Gp+d|UA61=R)%Z!Az$9I%Ys|1CFh zed&JrEV`fCP-dIsv^LoPyNY_AFR1MMpXXC;--;X4f4}aO%42C%Y2rZ1R5>`Jd!p{Hgp;Re+s#ZK?F>;UL{E zX#A@H$)V(3_BapJCOvl{wI81p(EdoD6_n1JyOpr2h41?Lah3S7%(ngaWEy0Gc^)_sNJq&|shx*IivL*ATe+h=P!lL0cM7!Ux-+K3X9q*m zzcjJHo61?72690xknYiVmDwgAa7OQ>4Cud_XZC&1a_0N|sgBq0&!@d#_U*Hrs^_25 zBV|$L3huMZ7ty!|z&jsURC}d&BY2NTUv&S$;C^2*WZ%vA=27Ea%Q-t&&-T6#<^t(j ztsD5iSGZ4W&>YB~o&@RuWVcA@81b`sM(3z+q6yci)P&s3U}`j$`Dh>btmu*GfMvw} zw{mTFUPb&T9VOW>Tlza~P46?K!WRupnJBrpwm;D4x3=H=9DhFjsqI=iDE)ip0|(N+ z%Ynu>55$1O@oacboE!JO7)tho#(V+!9;^GOUw^I>SMh1+t(>2p?Gz2PcI1<<(w26h zCAb(g0gZt4K~c~#!ug;vXo~u$k6rsy=kYea<S{gr4KEWsRQ7`9mKgIl# zefb=cl``L)M{>==0V&;}{Q>3ri;DTY^q23a=Q#xG`~SY_f|v(rTuI_U@p3rtT&hm3 zha2~K{XSp5r+6`+bpNmUIlsw_pVo6z{#3eu3wYu+?$r@o3$6y@gBCzGArShVev|{z z=!~59ut*QqBElDY&Jo7!3d@z_yJ7510d~hfx^V{3gxW-JL z5B?Opz8E{Qj+lN2F&#R212$gQ6kl1|2hu;#4usMH!Que@_Q@xdD9gskhE@U6p+`mb zxvp_$)-tblOsKeYQ0lvZ^L&=}-k8tp_qp`f`Yq6ZZojlmyn`7zDRvyFK2=yp+!tu~ zOA_;0zi$~izwFpY8NaDOIX{iN){PbYxBB<`?1gND7dqYx`3@GA?xKAl{R4SGa&O_K zzdqYIQ2Rh0SoQ;J19oCuNz9jT7whh>iPjMgaa8-z-9IDlwN0s(s1m}Tl`2c4@|DOYW{(QfmSTC6D z`=06XJ;(h>%Y;c>PJZ?U-~=H1RS^X0FPHuwbW1&xI=}C|cHh#!xG|sT|1&wij&9CR z^F5yetNTS`OMC6hlKz1_;PnAYV*_U6PhKdynFN$?P_E~w)1G@6=RetIdatB}<%3ff z1k&H;`U7R(>VCU^m!9L|om`3ouj7*Pu^WI;aiI7iV#VN$e&~L^i@nHqa|WB|Q_jcg z{`KVi>g4DAw3eq=_ZLO~<3$H7z}M(=24f<*uMDis|7Ym`X)SJv)*PI6zw&(rcE5<{ z`0@L^vaj`9J|B|4vvIYGgSZqiQju|BIJHC3n@425_kE&V0?p<+I}j)QmY zK0g0F`oh=;xZqVlakCgpJZ=3LKDs(habJzEsPpvFKiC|9u)1z9{dbe|8+v{Z==~P3 z^F6ZJqIWRAFOdEv)d5}~AkYSs$PfIB`3d6EG2{^4T#4+xm3xiTKe{>h=O_-$g#K&2 z^tXE7=6h_+=c9kY`Yn?GE!cp`o!sZF6$f4j>VZ_?^<|{{!{_%?+?TUQ^xqN0?<*wx z0rPw|=F_u%+eg)Q&UJHsA3$%d=O(|}`hCIZU)FwrXzqpA53n(S-P>4T<@e+$ZY33= z{ElQ^K9cNxpr6D~|IFq%cbbtGR0+raPqaQ?pzPlOKY#5m_`ti4!+%>RPU3Q{Koj7N1I6dzQ+lMGiSFMSNdKZ@ zKG}S)-Iwm)Ikq}Ezfp{_a(@9L?HuU; zi_hmTnCHv4`vHER)@&5)x{c&?_m689_aHa1I?u*|QIEDttVOMd8~2q~_C^1n$5(Ya zH5|1#IY0btrJE!396nZ{T^)u0P`lPoe-~Z>4#C*FZ zabpa=w{B z`qBExd^|Rr<9y)W82|49&GF~^{UKt$e7_I9FWoPQ{?vqQu94)%ftPT>Q$a3BTr)K5 z-+{WnBr%`W{cm-?kY{^eU`%HK&G$(DL|ZSdEf1{y3Hz`K9l>1gx>eYT&(Rgw=FQ9S z7Xz@7?;AO0(^vfeH}=f=+W8>uWn;W3$`A0;-hN(8ETH!qx%DXW2bBA(4N`#O!DFuj z9qhhTsH)VfT zeAz!V<`eyYMgE63wmCmL-y_<1WnFw=zrXkzQ>tw!|8K%$K=oJ(D4QA!#`{@p?~4I= zA(31nx@s0QS%!{Yb0fU<5j^l~0Us2X{?Z4Q?%GR7X+Li}!*VlYay8Ig$!Q=D#DQb4 z10Jht77t9zh5nn08}nJ)Z|RSXcX*~_68&ifWb5kPdwIg;+ehX6=%s&2W4>L`|7+#^ z@@``sKLncZvAW;V+p7cg`Pmn^zs9*Ckgl~pcNm|8fuFUt@a@cdG3buVko%eN0P(z+ z{`&0I0Y%vWwbSyzRP;~vUfiH1Xaed2rk>TK?HL zr1N~B8wbh;oc3O~)NzXYO62#szMgNc&y{`r{!e zZetda%ii!H^e49W@_?8A`t0Qaul!pbARf?jA`id9Mdc5|Tkf;zdF*3>bb$Esm~0FB zr5fjd)eSyCch+q?P+Y&yEBm59wLLr0`Ge@hl@%kxnl2fb@!@xe%k>A;b}H5vMEhUx z`4#uwdC9empK84Ad`~dlZ+Rf3{FjjaBE=VA9`d>V0q9S?MkpTe(m$9DusT5g;AJDZ zsB}PMU~^@a_*@Pgv(0(5D!JhFlZg3PvoF{?x(dg9^84(XtNNy^^5+yp#KlIkn=lX6tsUIXud~tDqg?eOLzM@sQxbn{Y4Jk zL9;9YJ@95|`WIIRh!38};Vvf~Ap4}8fNH;#7gSE@nCSrLf7Pf7Nr^`Orx%s)@!EY$ zf34dn`hPPf+j*x;3cizRKe}n$ZRC9S*?AsI|KiT``E-At^Z8)v{Nf&C+&%)z`ANRy z_kRg2y#x1UNq>4{egtdIg#ON1nBu5 ztNTU&U_7AT%9{RM(o95utVg#Ui4Bna3->+C1?49<0d)W|iHQWq)&@LS)lk>j82bNu zxR@_c_GSN7*Sqzm>^}yc8*|U%0U4_Ub$>}@U%LO7300l0rwwAfyz@O)_y3dhhcZSy zu} zKjh8#cys-teJI(tb^Y+fUgEw3KKd8qc|O0-@Adno`+YgT7irW?VCQ=j^LuIkPto5U zVC?g{M?%xTxH@1pvBXXHb4A60_!ar{WyjP9jEo2~&STZ&1GK^J?<*?b=lcA9yKi;B zt?MstzQ_A4ub1|_(EGpMn&*sgbAE3_Z{_@KotJ#QV?}>%ZC3RuO#jmAfTv&QVl9Aj zrPYA)0LR`3e4?6JF(kbz^8ZEPJfBzgZOkXyTly={Z*%>{mi>U3&!_vV;!D(a&da-< z0iOai-(z)uF#3CSfIgSC{|9Bq&zul_j%MB?6ED!0sVhG z-AuYRu{`vD)z$j}x&Dy4-$Q%r^9POjip=-;V*cN5%X4Ov^GkGdejhXFn(vXEg`)jG zN&f|nY0Gb+>0cZhAYPuejxw1J(7X|->8JlO%>&W~cXM8>ZSVnlL;r&|-ajPf^UHlO z*|+pBP0T0VFWZ0DrPne>Hs`13do))sI)CNg>vJG1e;iHupM4gz_rw4;9xRCt5dG(W z#Z+)t2Ar58e?8^ngZ__JH*1Gxo(%oJv-J1sew**Hv=3(YgUWspb=@}KC;I3=vKAo?%h;YMD=ZrA0&k>BWipymtRcWnX^Z3UYoqasOqErUBJ>Ydq!mi}uHi{>sgP*_T86 zJ-$4YrM>n=(LXR26#bWwRBUzw*E|m#U;5wKZk!*^G`y2#ysP(%5%ZPU?+X(1S>69^ zTbuKn#W-ngB733Wlur2o$bivQw3d7yh8^UZmtreU2Xp4-S;9?a*r zw%@D!k0j<3?WOxSk@KsYsye@qfaZIw?hmAYQ97VZ>2GPz9PJtD=p*L0M0<+ zi_!u5-*Yd}_Q3j|lIB)L2fxmDpJyaLb8lnv5YWxizO*_(^q;>K|GypgP%T(CIOcP} zw4u=EJYB<(1H790zP}Ea@A2vWV&r;*(sLcF5mH7BRqG5O}ykxlpL{^IgLFh4-Q&0fiplFI)z z02u(=;?{z)J3cn&iRzjUtcLt=IodJbZuI|-(Y2iOtE$d#D$sn7#>wmVd+BaJhorye zR!#@GK(@6aP<$;L>xF)^5WRIAy55yXotFx@MnA5%`U{^v^3wirIv^DNKl}t1YsR%z z2dG+L-BbK`+-!;i-|mte$$D*X1kUq$?S3J@j~L%O&+C0hQ{eM_%Jo{={~g)?tk2m8 zlPXVy=61eEV=MY@2bSJ}`(X5c_9X_9IL-B~YO&Y+s`9JKv4?_atz!~FGLTH>f!hDa z-g`hrv26Rp%`ihwl5-NtIV))hN)!+f5RfD}BS9n%8B8EKNK!ILkRUlJsANSl2&e=J zg5*5^9*&&j^__EWc=x?^-}jwbYfshgs_yCDzuLQY?W&#uUugq<^tOs$ut$wY{|x@W z=Le$tL@w$cQNBOw{!sP>)%Q?#=|3tVi#Pz#2Xy?s|K|L0o#!;(^QZf}ewy>4*7#5H z-vY<}I1P1hPFxPIjh)(l)LK6ZZxsIjXdghe&&UIXr~#53NCF_CWXuQ>{qGP1cnn31 zATfc&0unn&sP{C$bJ^v=eaGPS#=-kSVSh^hOdt5!2B6*%z83~w|8yTX%J=(^@%_Pj z`86TISm5|AT7D4!lmF-ZBfsxoS>r#&9%cJCg6Max#f)3P-|*l6CFb*^?f+--Pb&Z) z;3`OJAi+TbW40%5h{h9K zTYm-0`t;qtQ``Ud@JH$V={SHYpWuHDl#UKasP|EnLAnIe#XlibJ=l&?3jj=EY3deL9tL`{J2<}v?JR0eed_m}Vt<;W@cljd5&zOA05A*!|DPie04)3g zz|t20tbG0}S%b%1!8V9H;BR!Kfd3u%42o82&UcFWkNNLmk2-dW zKPvx#ve#gYeq!gBd>j?8|A|gw_^38}AjaT#qWaq@{T}xJUHnhSfcNVFa2tHi6Wu2V zb8L|QBW?e$_$M&~$Y>T|#+y!b)`z|f>;p@n&wucv?f=o|U;5K{&o68IpxxgG{r{z9 zf5wJ)E_7E&aZzJnyE%vfhy@Bm6sAAo{$u$c=>tFZ2h`fZ&vc6QkNGL?KjuH`|G%RT zyl4f(y(oVKrTeJ4$bY2YcZ&UwIkJEg0N?4s0oR52!Qb<_iW(EZ_xljwK7a5(Jn5t6 zeCQKFN&@>w5;!*ii6LnLMScu1VEHasj+pad@SXKxaG3I9(CK%k6^>CSMU8(b8Gt(Q z))>Sc#Q#UEf3N(175^d7-o&_q&w3Ul)Ex6v_tC)e|Mi4=29zB;B~<+Elu*76s{ZHa zMDeIPkU$j$hKvB7^Khb?ufA}N(QOAGO`_uBzx_`?5sQahoA9Wv9{#pP3SsQ?| z`zS*BG%xEwzfTZ+P?XP)57Pe`-3RaW&(bNj|4RP-x}bP~k$({XKkEahnE#xgV*X=( ziusTE&-njdA2{{@PxF6d9`Jq<{9jrF@F8hILdBX+eg2jQt}2b}8s&-tm||CpcZ{g3&-%?2EU zzgGap4_!sUha(0Foa3B~`6!>~uk<$(K;b|BZ^HjS!Vd&v{5@3wa1-V4p~icZ-lP1z zzpw{|@Lv-O|4Gn4I`#km5&i$)90UFtKM)mLdXowOcBpkERD2IL-lJ^(U)X~}`2B>! z{{y&Q^BIiConn8Qqj3Hs`ge>0sPW)O`;RLBD`Nm^J}^8Au3tNYd;3sp&M142^7B!1 z;=jgwPzZmVK+ypc;F{~_g?|qJ|4ke4XMW%qIKS&n1pj*wwT_6w9SfvWd;b^qpfLVC zq40kT?t%MM^I!4*kM#q=F@9_Y{Qn`))>p`bTX|5n9;NT!NF{2Bg#)(3u`1D^6f&H+!$f8zsyy8QuMKb%|w$E6`q&%xO3&_fXO z>)>w?^MY&1D4!nX)1k(86rp^(zi#BzXMyXaYQbLdBpyKL#lcj6cPKG3YSxx1Kyfn{EbTtpWfoLg4Rt(*b}p z0XVFq^c#gS3hTcx2ZiuImrzS2sB{68^A%9;N*G`a4J28Rq`)y&;@?Cf;B|<>>tlnu zjCP9YFJJxJ=PBlY>G*Fl`QI*`+SdQ={D1p7|N6eaB=DC6{*u6n1i;M@GT`rWA;FyC z$9xyuJA~p-^EGhZiQ>WhVKDS`CboR@1*_*9vyYvzsDnfJ0A_z{*QR@ zIZo;UFrjYrI}c`H3OL~b@Gy8AU;-PUDnK55958`rf(t4rej3H1PC)VCY11eUC8EFO z!3%&#!TJ_dE$FiS#DfJ7i@mzf5GD9;K}(6C$jy!JPLr5M&H|`_y82Df5aoedeoPG|2`BTKl3s_)ytrS zz;N;?C(!=^k7{*N&+tooR3}8Dq{M(KF`Pj88&8O8^h-VAFWvPQ9xM}1qlzc#7Y&{M z&Y{}>PCe$Y^HPy8<$^;Z=L^}Yjga(}4n|H>nNst0f4$9mLtQ9McxrwR2uf0sv`w*KY$e*}yH zb^Z_aCw$9q?SJNFz~-j_pjw^q-v$0Fk8B}eL@{6rK(z| z7&G`c_Pg!);lLjhBQ@0(@o}h8YsdJ?N(x%PC?^_#1 zAMgq2m{t%De#4&>_~!N{Gb`avt>^FU3I-Q-=V>&Kj>UI2rLxrPb|%#8R-ZhPey)Bs zt?oz$SQ*VOmE^Du(*W7GTt4`6<9GDNf3nK?#?3YpLx4`4tHQ5|5V|HVhEMST zjyZZ2s4^{#rIg1Ej`Xv_FRV9*v?PO1DK$kIuOQCMqz}6iGL1X2% zd&2OK=GYGsP0Ikq-02Zp@k8!MNMbcS-CV*UJ$=5lM9S}%sKMDqJ5+-lGn8?#%T`Hw zre8v)eDr=f(ADCSg@swM)<(JP2QxkrHQGh9pt}}=77W9N;sg3)EG)03YlN_oSPld# zWu%voWv*utA-ran!bxtA$PmcfCQCoU754*h67ny!A~Y`#E?ol8YGVn!QC5xJ#6JC@ z%(l=^uW{fd_wg`ClO!^X8`@s6PF=y)=j@_>})(DaK>ZaEIGW_|u%H~-uSF&X9#)LpwP3xc{bBR|L`H?rrSVda2okxU`E(5+4HoP_^ipcx{bo>hv zGY<~3>H*9hnqCEUtBj4S$siQoEI>IOu`}X16b&xFCw7a4aR4GVfiqkjsK*d^WdcV| z)qU2l_OD1fo936oJWxPaW1GEt5Fn1WaHFcqF@b&f$E|}nov|g2g zHE-xH$UY@;*~fk9!88R^#2h0K0}#$H(cud-Uhb6PQ;{83_d@!Za*+Z2vMq6t z;$C@?KsY*J$5sw6(R{CY%=2i+J3a@$#D}e+VloLwpDf5&UG8cy*yOSH(|d)X4R+od zwrK1C&69wwy`47002-&4D;)WP__~+z0W1yUCnathue=-kpcqAoNo3p(Em!WOCu!`Ku%5i>_f z0#T$6iBfh6F3;dQUg9shEK1pBv*ukoI{r%6-fhI5UC0@2HB4BCUF#;d5HMl4e6`X3 zFm^PO;@GKB{ASx&o*L2fut-UpArk+F-WP=wB9chE(jAT?m<64^r25O6F6Gy)n=$lM*!jUj$U z_BFH!cZgCcS5_TONQqUJVP#^=o+8`$!r6_1aFy8ksP0WEKkv^z474u<*t!F)oWz8N zyG=9<96ko{%qqc`de(;M)(*{TB3d_xx@wzEE$jdU;nrbq*V9O~S2UpsySGC;#Uf_0 zv^uog?Jm#Y?L<&)KPT$4%94u^%#F)pn$%LNEUAezs6A@0wh!3$89lb9r`}($?@dYm z4CkVz4-~P#OWoPL$2BEbvodTpj5!ldsRb|hO@4>Cgs%m^Ap^jUD&y4Dn~Q|rzCS*b zbq-0%uBK5_;MlV7X7w-oupOah4{PDl0ufWavw!t`GvQsfy~TuT<1;} zgG6Ltt}O%bR_iviL%=c~wH6K$J&>tZ6u}j@hm&>gIjh9*WQsxcR`M75DXK2`BE`&o z+Zy%wrG`|jMJ2b&`Kd=&Iz}$qOjWov8Mznju1b2b5Q`K8p2R!K%Fq0^c$q4Z+~;g_ z+;bf4mMZv1=;!XfQEI-(>~~|M`XJzXCATvdyiO87DW0r|iua?=Tzu5Xv+H%t+~y6V zc7q<14&9_V*9ZFj#3mxg*fLq~!sRhd+4dF>=9 zomt)$(EyrEuHn3vQ{n1PBytlgbWgiQ$*rz#R5JKnO@|3(356k@A1ZALJJ;^q7vkqf zkCYVE4tW|56$p4_ev{tnnA}(r@~K9n>q=S@NrJt>?mCu2vE5tak!U4*SdxKE*rc6$5c$^?=JmVjJxlNkyTyHtTP8@=nejD zbn$I0Nwyg;LiZ|4V%cTyGH-TD{0hQ##kJ2hZ*_G8fV``WoBZ#2fUh{{E|;lChq#2R`exYRy<^sMj2;;2KBHer zEe1C(rOS!r0+!T3e~g5Hrv-<$@LQR8zS8_R@jKy6N>4}p$1UnS3ZpZaWiJ{F`$Tq3 zaG+V=y-HP3e6|@IsB*U;>okBdY?N~FvTeWUcWxP;BH;4}{2h5FSQ*b}bT()DYWLs9 zcYr>a{&1_oszvZ<*D*s4_o3HW`b_rgbBz~Lq3Gzo{;K^c^R^fON?f|Gna5QH#R3EFup|UeB2!JskbKP*ilHjO?A}zwZo2F6$Wv= z&Ka$V_Ojbp9QkVP%f^~bReUP$Y8$!!?Q`ztYfjNnA}k0#0Qi^fFG3^VUuR6^%&JmB z8}!7y=0lE%)_Sy3%OUWguC2qdP}!3wl}bU0jc1II4NE`%)*f}~qfgHC=y=82_+0R$ z%xFId1`SDwT2vE!C*$jF>2Pg}X7zGWuE9@pmMoTnl4 z15Y=dYbHftF4Xttz0B_6K-Cysj-PHn+lS=)!iwS3(BKJ%*0U7%W}o9e5LS8`JUDU= zQ8)}W+6KyKi3)NjDf11o3Tx%U)Qlx!F}cvQ0$%Xu3a#)4yVBY#7b$&uv#@g0nGv|z0%Vx6-TgHSJD$C;`63@eMd0^mG*Jex(<%)>Lu3ve_ zO&*)c>cLww(%XN>v4rVr)dTkjENJnItsK$$-0@fV1}~t`SH4(`wur~UVF;z*9n2&u zW~+kxsNe&fLYaJ6M!f*z@$)#rs>MZPqUR%uU-Jh5wst1iOH<;wnB+O9-s0vSZ9>fz z;mk{AYxbsts|LK*(z=g|Q| zbOY^q9>yWgv+r$)b>_vMv&vpqefQi<>^2V>76kX+M@}t$<<|2&?8i{!?$#M^vS(uj zvN#4Q_}DFVFQ3d{k;bRjZ=iE9y_7Qjx_jkl=Dowi7~uNeiE%bxh74sl3n`zm#%4}cyq?f5B4y;rHWV+Pm0oAgs!Z;QJ@1kg z&43lRZ3$Fqb>wF{N_<;Q5#JTn4KreQ849hNR!5Ha zs!Gc*qZfX|d33{a#^_39 zOpX(o8=iaLQ*%6kD61q=3ySfryZ^~>)$KO4<7R`J%YAa(+PL7TR`)aNeYaXkKkIB4 zTsnp;Pd|eq91%(uPUs1tUvuwHu&{?tckhoUIW2BZTw-3zMQO~=Ha!>jBU%s-c!5OC##e;6E8t&Tz&0}4MU~!YfA-n%*R@1 z!q!O2%za&YNkH4^|_iNn&az1ldWM&Q}I(n}nSBP`Se9C$=*xHi}m_p$!dFFHCMVYo&XJOOo zLV2vRktOH8OqHcNFw?0})j*SK44#Pw8i!mliZ;ri2xFqVbC!%^Hf7tRaT%CCNL^Kr zP*~(uTWzMy0g$f;Inkd)aTv48GI?P5IGHv$dSudP8uOOijVNZ+G*TMmgqIL$-{xfl zh!D&EU9WV5YbLP@2Wwx^v)!}K!@qAwpX9na&|;XaVE?s_sx`hE)A(NHXAmv(XOTjU5*%1(^06GEWjSna zprwhW1Sc1dOPw#~OT5p^1?-j(L3nQGg^X<(2{s zb+i!CG3C7;k4!w`S3hQkv1n#N-8-pS5aR6t*ha#hobSk@+9Jcr>x=-zK~7xc*ZX?G zp6Ceo1@`r9#8_YsYj=h8I%N~Kk z>o^Vfl#lfm`W@yk_A+3b5{D7e_+t~jXyQ#o2e9Cx*T2HYt}|Sow|Vk`g{6VX{mnxoLVK7)dYAXX?0LxigO-qUg;#MR8p<|q zf+M87N~O-6717c?BHQEyOl35*jh+5Fna1pSHoglaLsrCidX$60@L^$f$GX^tgw0LN z{8u=w?Fz&em=0w9*Dd!8)Aw@Dqx->j1fu-40h2mzV0u7~r9Wl|(Y^bs*h6~twWBT$ z(L!w`Zf#Q@z2ahfs3|e2)=j4sk9(daghn((!r9zcqKP2H93zoE4w3;nAro9KI0y6J zAP}r?wh0%&r!Cp*$k6Sb&#$$tF z6$lMg1qP<48r4IuZ2^fK*l_>BB;@M)9pS`I8Mk93GhRZlP9Dd_A#`pJfWHCx&T$w~ z(}sl&QH<2G+E)E2uSJa{3nQTGqDQ`|efL?j`EBDN@m>RWKTEz^=HYb}3-Q`L-gN^x z4wzBz9XR$nPR*6zrTKbp*0k_gOitX`M~a2Fm4U?PWYhI>SPzw?0C)DYAzvp4Q=ygu zV(}{h`$_4kNgT$Ko;p>|CNJB(^6oOi0KDSn_{apZY7#*m<3M96j+BFe$BbrRD614R z;o?&bJd?K)Bzq31bxK!b@&^0HAg8Iz|1$;u}YY;wM>40b$*G~jibw;jCi0X}nxbJG=e z(AK31W;o4m?vKa$z&aY0Uo$hmg~^vzzIxSwmL2VYWu>B?|62A zqDI5BcStoul{NX|*U^0luWVQLWcci9*?ye3a6?Yf!HypZ6+&_-ty87nYghwQR(WG<@SDOY>0`J#}?4vUX^~!98%_Rv~m_! z!|vt8pazkWxh>$cUVePDjXAn2!)MlVGlo9tdNHf4hk+Y)<^|ai4IF(1HM#_73ythj z&<<{mQxmQQ`6A@eZk_9;te1Y71yfoNx`y!Bv*QFOeZ~z!u=3jR9f>PD2>G#b`q}s% z^=8~|HEO&HQK{9|pigLDE-M5css&vQ3MWIq-v~(*(A>spc+`g%&4{G+v|B5pv|cUv zxSLbD-I%TeIf8jrGq9H5Js#q+(V);UCxtq#pSCgPGqT@dE(O$@& zXT7NpFplrM&rGM0QBNv9tBMwK$NuQ4z4E^3%C}9NV$0H&ix675pb1>Vl<6msp;t#i zcJTr8)BRH~aCI&Puzea18CE3+{2TVivWdphlmxR#+U{@rr!*6cFGJc zI9E+vA|FcN2HYF>Zd}J&sa8>ncA>jOMcJd1Z~dxwR$bc3+v!?VI`3yp!iEszl*+t< zHkJiWAEMd;9FF9Lg_-5Cr5lqxUV*`(ncasPa*7m(glMZR)}Nwkw-0F$!6sox{PsB_ zr9rcH`e?K)A6RnKJe?cJnM%sh}uwh2m4U=hvWp>Ar_G#@$aUa#*uGhzNvj3Sv~6h#$dv8ylbz|wtxY?Bc!Vn`cKl2)tSSkbV`4NY zku5-){-Nw{d9_IS5*oACWyR~T&-Rn@CSQY>5?W2zrk`W!uW3i^?|&|624wan8|2!( zby_m^irT)M!v?rM9Nh=wA!0gml8^M4Y>3})XrCR8jY^%9Sz2)RZ*g#4-W=$7>*jw9 z!&#CurF!xH4gD}brqid1#Uhkj7?y+(Hx9qHOXqd1%aT6lK6J0Nf1UHtj?4E-@N@Jx z29%QOT?l|z4ms5l_39o;q|Vu9GH4qt2A6|67MqWs z?e%@B6}daH*NV=k+9oZ>eu{EE`G@EG+Vu>E(>4m*O*y1WEy5T zcZ-C5wOXpG^w^|{Tkwp$iJHFoT7J^t+L-T3Bm)Wsk;#>&bCRxaZz6OC96O%AY}(YF z#)*9_C|Z+ls7y8-oSp45Ba*jQ8gYY~zZS`L^i=FHT%I}2NG3JUWQbT^cPDDP#!YG+ zD_RL^Jv7MpF zQA%Oe6Tk~I-Qy(dZHyVHxX}l?)dx2?)Ua6fW^aVy6u3F9;N$U4$<2@Y@p3Q*)+gSE z=iE_%R6=c(KaI0MSxBPQPdjbZ_Eps|M};Et|mBGq+^dS#7SPuUs!HhItFR zcn~`gxjaGKhhONXNF$8NTn$!~#)(>RDxZ3DSRR2as|4V*aatb^QZ-8CUHo!Ni!L;t znd{LVdZ@$fdk#a9n5n zi(<-V8J~Fb<->IcEdXA1GZ$)LI%+-roaHk~CR=6_BIdSjrv0TQl{1rP-8lEKyrn znqE!9)W(dFGXJK9eEYHellJbt<5|@?btwyjyoV|CX)IJS=ro{6-T_+gTHbNsioZs#TW zgyHKSR9aqL&^^bme(YE+79LIpG?N#-W67t$*$Zq`bV)_3+)-ul+3X9q5IBf2p_+Tg z!tS2dnx0DOY?xz2YY~J(-0kRj;aXH!^UbLt_RSC6evFmULmaxoS2} zU5hG+1afEIX-v~J!%Rj9&yFNdU06>|R5^_55pa70wA?Y-x08sdpU}vb!3fFX7YF7R zn@MzD1`G<1iA00QpMN^fnt`W>VDD81qtIH2eveBD&a=xYOYx8I+7U41?J{7x@v+RB zT)S3C*pj8uY7#)ybZba|$~kRSf@+HM*k|;}zN8aeK>nu7VR>^jcLmGU{+#|f;#UQD z?i09y>5cBvKI@UP%NqglebsIhdX2*8Hzl>2KX*=PJWITD(Z)xH>EqU7nnk{kkSOWM6EuRY#lR%W@7} z3ZC@NNWHW9S@64V*O7yBUb{3dg3kPnFIPU_xVn4(VnE)2hQkvXZhUNnbMvu0m9S$} zz4TTBggZ*5UC!SHd1ZO7IdgWa!s?MKA@a?|Yu(ZsaH_d(7IqKb;*r*N%hc=D-T8{) z7qfC0uUeRWWRyI*wmZJlCgV@`V4mwT1bt zB#FwJnH0H~+}N(q$>gnT%xc^)hWX9C(`LjiIh=20v3bPUaBpDA`F`cK1@k=d#n|vK z75rXktckL9+;qv^wAm|q0s)h{H~98BT7nVa5={Q1!zC8&p(=IshKptrXl!liHP6bRZkzRZ>#iSEu4wkbA4kSkAIHgs=1%qgJNxxcPEEM%#vKjX&vp$nXCT0?h+?ME1uB#TZ@ajw}z@Jw&k=_rAO}vfR)p9Skt*9TWCfl`6G)@MD$q8XgVy%kD5b0fd{m|CKg7w)rEOWHL zb{s$wGeT#Bo=A!e151Jc`ji-t2^QyQk`w91wqd;!702M!gFF&_?|K1th0@zmMoWev zm-j7Q@Z(#TIfT`nYtoY4?em4uKUU+t_t}gxBPKJH5?L@nL(Ym#Yari`z}s;kxeC)F zJP~Sl$$?|bz5sRHtVa62vybcrurIVVWCPbb+d^L`^32Kb?M#(vJ%c9s9=qjP7OXvU z7jU3umCYFQhAE|GUDXq+y!on_%kc86w@|+@jGVFQyw&h1q)@|o$7Rc;4gv`FL_MQo zZaWqrr^1n1>UUreQuB68Cv0|`G1fGqyeclEcT2_xuYPycvg@iXGR-*w(Vgz=V9=)_ zMK(d|b_1Ne3l7!=fpcs>3NdJvmxzutB8hO;M8C5If4C(aNlm{XNWfbjgizrlqQW9S z3%H0erbyfh8Zj{QV$e=I``(WxdtF-z4YHa1wgL8DZmBd1OYcIo9)v!G z%(b!P+be?+hv0?;O=d%p5<6G^9dq5|&^LTA8mBOs1L1}zU77?2IcRz6jL%5R*wLmV zA9!Fh5!Zhko2#W~mAzQ`(Prw(-95hdV*#rZ4vDYn58j#;P-uiW#+u$osqiQ0%u?<9 z@?H^q&D$_h1s}1i1FXbKZHshkSsE>d2t3dS$U$Sn3c(0&#{eMFlQIVdP9Y;y4xKn> z$lF1m2OR( zV|NPQYR^9`N_Z2@j>c6II(5)oh8g@#D4#62H#X7Nwj2#8jg3)wHAaW!I_Qwu7hzJR z%x_xa@aSkKAP)RqGE6WmbJReJ7XzSKzC$%dsT z@hZ~bSq|J4kf#=%fFX}=-9Ou-Mx>RzMO**2Ai7U+rB$10vrf!*vRDLG1SO`rHbTiY*9<^%jQd>tjW#P_* zf7ZR@D+N>~2e;OIko2O{)dcy6P+!}(Xz&9X=?U(T>hY_x8E>z9AFrKdK$&K7-`yl( zQ6%>aADRLkrS&I`uLmCy+_O7+g?i4hrqNbctSv^#!xA08O>zszJ+0`i)Mvq?5}?gQ z|LV=xP&zFBslR|!=Ng8jtpFNg7sg>ZFmxcw^s-m;@;NfRPnN`+WVFl6On3JRjF3W{ z8yOkve1(#{6^a4E=QalV!Ze7lP*LhL`#{}dqo0gVOXLxFn$b8^$0xufuSjU%<7LI&9ZSi2ijzx5@|n|^&sbIpt}Q=R*1 zj+~mb5*xHy45d1JP8=J!=v)-3O=CBe(!Ug8#z$-3CJq)>PT4Y zZZ6Ao4yjI1%VP*4Jmms0)`~hKl|c#UPvys$xK<)VUM)5o(0w*as9gS*LHp4An|_>U zVGql~J*%i9<+Ro+v72UlWJ-rp=?x!Pe0j0b8-_dr4{5b@zD-emfao1b9(Hox;GB+w zfB`BB65usi^Wor@9LY{$ibbzl&@-LxjLBO`clP*fkj-{vEySjWkx_jq==XOdx+vsY zqGvK-eK;(mbG+(?4+wCD<{xIxo8H7=?dGy??s%%#JKt$=JZDx2z1?*c3qsj^sWfa( zTTmb0`H*hp@qhzg;+?{ca2`UuzUTVj@ ztn;f$&!Fy2^M2fX$p89Kj|*$zna=Aap(lNI$=f?YN(UX}SrGZAvAVoWk&JchwnlL7 zS)kQQ0qxcSJvXY|yeml@4Cp0^&6z7oYy67AhD`O?T4{4;WpEtgmHwC@3kvs28a+MC zVw<2|Ja-nxvlF*N{j9mcAy(s}^1=xAY6Hyp_L(qE!%TLhZjK4<=E5^B5-$I}jIWpm zlb7el76-Ptjgk~pu5`dIN}}z*+-m|S#rB(}TTq^1;Y^p~=_ZR;b#69q@MmJULf1ZZY#2-k|3#BlcnriCv1K5gkRlqku;p)p`I_A4M%?p<6G>4RMSxc{aWlH zry!mpQ&pN`bvdi7sAGS`Tl{)|vajA}nqcV|)LETQ0ssP7@a|Gfzm3gC zFuhcUFz!7i$vE+{tp*#lcFV>^{b#l!h$` zC1R`}^+BH=-#wVX8=&2FsJlHXF6XXONO1)Ri*ze(fMc~rPDS0ZDfUgmtVJ$g*m?lD z=bYG1d(^>*eLP=-3WK;kYpmv>*usI>!W6p3G0t-B8RIFK;WsK4ill4&ek2Vm7rgc@ z8g_W!0flM!S+5b_{wK z6b+a%lkBVp-n_czUyuPkrWu(v{V40T{8)fEbwx~q`GySbi&_(Z5xp}zwvvU`qDMWp zNrz~((u91@foc+)HpWHjMVU2m0h}uGw{QrKp!+UMdT}jv(|LfFO0t2U2Vun05uJ%0 zKXxaP8E7yFm!*QzYOmTOKP4R)n((5X-+rC(;I;Wzn9sQQl9giyr&>_picP?tnwuC~ z^#siV)GQ`n4Xs$smJFFkH>~rrE7{ZF%vU_Oj#Q4eZn2r&(iXNR7}A=$SK-LU^$KZ= z5`2Rv?DM-jK5xoA(ptEAb~#$;;lfp`auN53I&ndt@2)a`NEN(KgJ6C?U9=}aHawsM z8)`)l^RxPF%QLWV%t&^9oI*=JUz9=LiDv-HBtGCnqkWk!yW&%482_xgs#UKoB5{-S z6=}=9p$M!@9ae1U$`<(fBKzT)!(GZpdm_7hT{B^JB5{Mckt&leTRdg!X6W7TW<@WX zGh~xT5wC@EO~okK+%jdP@`#1#+)jE9*?g1LbU51hq(-7VagUF;Lu^WM=CIXoVSlv0 zpz%GTsaGNW{t`3Oxffj30`IDrT%rKg4&5&t3z^+^yh|MQ#(Ap-WDhvceJmC%2f#_F z-Z7fAD>m(`>f6FT3Wd6Rc{t8NgEBBNZK|U*!-!FY3lsSAgT+CKi6Mr$-XLdUiY|8N zs3Z;r0YgG)GW%S!_#1xAru8k^0ty2CE>`s9^A-|&xtfN!A@7qNOysA+Ce8e^(h`6o z7E@SoP06EAAFzql5iUW2=XqZHV;JuVAC_M6;+S)$;R3g@;7?Q$kF9;U>@`GP7LA4& z2N(DZU1{Wu3aXC>$655hmbXtR>@CaWZOgnqq`q@= zU=TDwb1POO8}hba@Zs78`Eq)_R@YUSsJe@b3f5@fi`LZ^lUc@>^!VvdX*e4ad@q=7 zsO#gW*Nt8y((eb>8c^$lnEmFf9fh_C!l}VZ70t{(qYJ640o#&xnB>j(T|W+Ce#t2p z>SLZws`obTghx>;>S7dr!r`DY@{zjXSmY<=LraWs^Zw-VSW5)YWc`g|eBK~!0R){3 z>+><5@fcyo;Qc9j9`e3dC8F1biv0kmHosiNpg20W12){Vc|MgZq~CacFzba9+K1Zk z_t;9xU`Xp5=uxuR9Ry4inWe#=%_gt->?ggys|Z=5DDnIt04Z;cV9f6IvgVr~GNQo( z=pV@HGM0T9{-kxyQWnx}13aXR(Inv&X-xaT|gQs@fR4KCNIvmNA|K!ik& zLRY1;yh|vR%_`i@f;cWm&fj#(Y$EMd0Z{2_-WPLKx#Cc0`%@ICd_y9HX&SMn0?9L< z=X_o#?%-@DV~9@%2A3F&buO)=W6JRJ2QuCwAD+EWoa` z6%`=v#Ps{~ICKyICHLjXnSlsrWe)8wF)(T>UulOadDjlyG}6|eCI2kChoo;bU@%%l zgDzV8pxIY!dTlPjzSuGj>3wqkJ!M$0tt>seFCKvjV)f+hw4Ce12)ty6;>V+RZt>SCrH z&3pT(=?GxbAH9tOZGCPXx49060jMJ}pA1c2snzwkQMa^F9|d#HT0SjP$Fv%ih=Sgfs@Peq8b>Gx;3DGI$40yoTT#BFu;`D+V+9wnth6@E0ifQV%SI(8wR?F} zi_P8vx3FKo8)>OfI*O_DcC#TCw)6oPly72E=o-yDx2zf9xs?^0C~WF2K+X`J=;p$2 zm%D{0Rsc)bqF#a!=jR|`m;fa6=mR0|tGc~#Ih-WiSW{bvX@zeevGYKe@nF?E+WyYN zrGPnwc3Mi!=IMm>dlbCSgw=_h412(NG<#2A0yjDOry}aM0t$AtoW2*+FCoI%DvbDa zshPM32H~MASni2&TJ~Qjt_UCtd`u=U2YDChQ~J*GYu);K`%2$nfUNLAx+-D7=f|D2 zSXXiY`?xW+pt{_*&%daTW4DnL5#5~OKzk80AZRxJ-h$FNCalZFlxEOy&l+DtcAWFY zxskesH=aD=fR_=LEc3=CJeu4mR_bq?{8?qCWc&nfEh;Y^ns~4~N$DbC53i*^miG9j z91YVulCwM`a2X2~B671YO_h?>&tkdeaIdtmtjoa*@?eplR&mbk#fxZ+f>{iP^Li4z zN5>t~S{I&#JcN7{ z#ilk6Tsq&pZg_|R|P`Mg(pLuw;f=%2pF@Si`*VG zBr>AWJuspG=TD@3^b(Kzj5vmdlcAOzGRaE;Ly`x4-HXk8{pha?j!crH_r74IhSn|7)(aAeH=0q9d?g7u9eXHSYMvgFBh5{WWL zzN8ydzR^&X_HV7$kBZP&22*duX1=ej@kQ-;D13Qt*xQ|)ji@FUBIH8M-XW40CC^C& zk2S;4SwU200`SA?IKjrfk}WJ(HljNpENSjVq(@8{|p08=nLUg)|Bx%Zk=H1O%ISIX1`@LtD$@^ok!1yN)- zzS2-_`xannx&Uu}RWa8rcf1_GU?kp(P>S_}xNtuNum8MT`%G3Gp4@N8i;)V`hGr;UMkyF|ySH3VS#%ln8?gG5f= zkF7mqZZ!-!S+zYh0C@+5pE7((8jvXGI^V0jPwg9Hzy1XaPsQg#&-VEikxYzy9zY(k(yt@E% zNy$pU!9w&*%F;2}g;j+ZrZ3e=rL3?d!_a_Lh*Gu_=7ppAy(?-#g3pvS6V_w*2Ktic zWabx2Zz9t)QqsKDnh*I%yqy-mEUN0%3-qWA7sMjU0O zr@czKMQ+7IL_pz%hU%+Tz1lm ztg?;z?2je`D1%4Sx}<~2k0Nf@IJ7hP93t_%%$=Oe3U7Q>3zAdW7oBQX=(?$mZ6nxB z0wtGtgC~q@le`lo{xuJx7P~7L=3%$e@#coV zHL@TnKw6P++-K{{OpvtHVO&*V`=nSP6}3uCAg8=`bf#`YFDPBhI>~Z+6x^ z1sq`Y-l_(J+iY7|Wmn0cM_$y5ar|nPId8Q@+EPS+H(3Ekl=x%Wq<6%g^zGL{X+zIxRF9QDcMt#rEwQvVRlO~Yy z1J9CtdXAMm7|tz?r7LH-`i>u|iFgEgTc_kq(-)t+hc7-K!u;B8pa0&>fXV!p*V??E zgNEnPLGviOpp?(&O81O~r*^>jw`r}B2Tx00797T4h3L(B3-GD#X>E9{j&Q~#$0%jr zWKMo_M@LmR+t6AeSE|Tz%#C>IVQM_BS&GO6)%Y1bK#no4 z!L%cuF$bOy!aXZ|?e)3M2?e=?bqeUyUJNWodkdeXFb!j6GgU5d8K*Ah;n({St?0VB zCW4(U&7s>+hjP(yJ)!n9^a(1O;^fXSLl8{soeJSTHdAL@f35#@iDC-#-L z4^|>M!AC-HF)pY!=gLLBarV?A+6B|~GDp5_i$V`FaPD?CA83g&^M^YHRzXT04a>2& z;qbe?6@S-VP@mG6!3PkDsc;;8K=ym;;Lls^^83lhcSw^`X(5DUu(2+ayiu#aKTC>7 zbHO2jqmfFVz3q`}4n9OD^AlM~VE`!(7fj!LS7E4%1>8lzaxgJvOPbrboiD~!mV|&S z^_zqkUL3t6GL;@s0YhKs_xRuhb1^9W-0%h1#zyNH3mATD@?rmi>LbRxfqMBmN16@e zI|^p;9o+*8S-#Rm-Nl@KD}~{;BG@WnLm#5MOGXk}dpC!)?dB$#$sR%(rwG3YPuwm4 zb}1quGNWNVJ@6X|zip(IafNsBo{c+N|8}m7?HRdx%(e6RVg#=kWnwcq58z}Mq$90g z$SH0WD#g`wjt%0_8{gh*nCfC-Kliw{o~L53caU;wO5;pTQZ}&Xq|Y|JtuJ(+@+;Xb zys$h@F4COE6pHtLaFyi6+8Gsa%e-+FzyTj+tV)4SepzCJ<*R({TYg7Udk^dG!qW9wtwHnLKzci^oCgptKVi0n{qd5^21nxL9XDBJrwrkNTo0l*R25@W2HGPSQKWCWXi|VwhDjNm)>jIcEq=YF z0N=(8p<(e;29`-^q}ScFInlbJ3AF^U(ES2tZN6*Z{ld`x*PW{QbOUhWw6Q~xRr5dZ z5&*AuZAgej@-Cpso58)zN!CColZ8Zy%Rub%tbpf0 z8MhSR!5m>ZTB?}PLm60;4$?;o@Xb_uJg!~=7zso@kXuW)wl!_o)q`6CaOwdTB=bK7 z8(QE`P0jy&k*AOaOu@w`jibuU(ccRQ=2ilDKuW1KbzOlF+x(P&&cC5m1CgWv_0+%_ zSQ(VP^HyU<6-vXXQh-<7DtLU(OoCccG;P?uO(dI4r=TDyV7_VAz48%Q)(#&Q@J4qH zYoj=%0yFLf=zGOj17Kx-<&k!C3IHnrl9}6)EcdHrC@CO}GBAV`pe}&Vvv37wA`zm8yNjA*|~mgX5CF)1X}{;8w6&V|6yob2EV7lFNP^sy~TXLXD8t3 z6y&MQ{XI{#o>Kt$_D}!cHiWn*%II(YjaLe&{PG^e{PYl2jPAW*#(PtndC>ug{~@JJEZgLL^j8=uK$#|r6yWi)z-J~SsOHrQ<_#ldcm`L& z)UJosNdckSJX~S?(N{kXpXA4R4=-xljnI)*lE!ei54X!rNQniyHg~=6UaoFraK7zF~+fku^Y(W0()%Q-+|Ds_R#ICX!99&D$Q>F}d%HuVOHN z5@UXC1p?iXVO(Tw7zywf(0VJ})C%v?;0>b6ExzI`WYoN0gBM?di`W6cO9GfP0N{2h zIrP47--fYqB5o!L|JTv&)eR-BJKm@}sl5ZMK*629!lx#~yYp6KMi8T^!J@MBQIzz4 zAQq0?I|ivi)BP+QAV`eekkTu!+>&Tp)moYynxMk`=4EcrZq1+;lGZB&$v=daJ+P$> zZcM|QHMm2-+PGdAra)1`{b&X*eIH&x&G=sDTFlM7<{2Thu3!J&9vmm9DEo}F|AvtQ zYm&8NJuuf-qXJ39lPP4Rw%YZz8wl}P;7xx znep{R2xXOm$)qq2>*&UiF4_ci0m}unTEDC`;tJphJogXq8kM=f zRrEJOKsg1dyA|MV09xu+3?wd>yi!fI(mtqps#yRe)p^OFFn1x{Z}ZUPyPiqKt#?}Q z&R{J|kd>gya6huNhhNL9>umikIlbX_F17c}4qJW)FvLAtFZR(NAPbW}g6B#Gqg$E3 zfL#JGbt<-|tUCaF^lg9ET;BXcxVx+L_=}^n$Z%h)iZns2=KHKhlVE5d@d689;FNw5 z74>%CM>7e^;3y6fQZnAr!}&GWDi30mjw|5Jk)OqxCJu`FUIDxUk3R_Jk@hA46daf5 z6ri2}ApTTJnOwGcBZ8Z~2H@G-fZ9vXKz}K~|7F&W?u_lnY(67?gjIq{#&(3oF9k@y ztRhk{e0tZ<_)$o=aJqNr?C|B~vl=%o#A!iP8ig}ohsU$_xxb3}tq0!Dodoa?0LjeF z#^xVhqkZ_lPO(3fP!MJ)R=rR9JhR?KmI8uG9Z?GKO<#Lgi?&M8`Yimoe0sy}%w*b? zt5zibAQ1QHztG654kal38+`W%aJn4LDf&A{f13-iNp%H)V^_U7CA7r7nC3>GzN73j zRg3)rQh_Q?EK-2^$WJF=5A)-rP!u|@n>oWw1L7-&hZG=nMwV~o{K_4n&SS4PQ^z%G z*ID@|kHK^Q40}LvoXGnz8c zBTPB6JEka}>Aj`_Q6z@oX#P?_7?AaTp^%d4mgSu3-RZe>ts2IkE6JdqfTYS>02krp zC*WVt7^nV}urs}r`JLjwjp_ve;wmW-sg`R&Y*9ssI&jVYj7u4)loV7j5HOGTDpZ73 zg5jh9AEjnhjTyfbP;DBiY4mNri6jx5Y%h0vto4=f}?$jaJWfe~iDqLP~tq;Pdr z9u4mJbYA|n!kJF>233gFHQ3YVUT9KJ+;ic`y=$}MSIb#1Ur8a|%5%5>CoZnohEPj| z_Qtb@*xKCMz~J5Zgj^pW8R*@~c-tyk#s)m60a*Tec}V-!C@z{S<(~kC;pC^_D|=xW zZ{`nD{weXZE&$MU&u=#)wB3034tL+NL{s(|zdmG(;|7b;_~5ar$3OX`EKC=$TbP0x zXrW9u!_w^L7D-564Ogv)tL*~4U})|246odd&@1g_{okWuL%iNU7iE6sThl9WaLx6s z8XHh=UxPpMS4;DyAEakJa$ADrp;YaCGHhdR`hh5QWn!STXq5Yw6Uxf^GX33B%4n^PpXjh z{)&;5fif^nIFq2Hm}=q7hFz{LY-%wFhNx2HH1*UYSOM+=ocL?_{1@Obc8$OJowMc3 zIrsO{pSmb^3E;m2BAL1sSg)?0&$8U0p~LY!c&`AE#r#CR|M}yVMsO~ZH1$sP@;aRl z(tu>Mv82`pl>+D;I!OD}5Tl*z%j30|6ySC5N7bY$#};s=_co?8?WA)PN)XU|a$PDP zPXOoO@TcIPzgs!r!>Q>v89!+5@7pY~1Hi+HRHF0UU%LlLyXNjrPIrAt$DF8FEVxt8B>gg32 zmFm?o1W<&+EAZ(5hHoB*OH}%-b>ufke<$;s>!~6cPT~XragCJn^soNQb(N#P{5AQr zE$X4{3sfL0Gq5C-vQ!v)?FbcQG#k@wMC-h%o{P`sk_4b76aZkowhiv5B|FYka~DF& z8aEwLC#*&psB06lvS7dIMLi)W>wqAagbXIsFz7lNS(o+8@aIj3R=&<>bW}5 zPZ!{Pb5xpR<)1zXhrbB_`XxAAtW?!$>dWFi`l}ZFgeqEG03ZNbf9_MuLEPz<%fm?l zv6=({ed=O=VesXPROa|9L|tkCAOMj9y#T@#YAK`2|91;U=oy)(rDjNnH|gGZT9oKa*Obq6$vjwHVKzf80J6o zGR=X_QgYRq7fBa=%z*(^FOy&?k*06c9`ahk`&e6QWBj;A`D&yB3Hk*%{uTKA@4&bA z84fU?7p%4ds)619z3Ivq?}dW(z}yPEi>+ytYe< z9CJ>%_bXtsC- zsuY2K)_{fhAt&L&OYrFD;E8>eLp+?DczO*$)&a_Qoqt%00{{fTx9 zpFhY@SFh2&RVex6F$vnA33Nu6ZROnRYgkzZ0Ow{Q)(;hRhEk`wyzeXXFm()G{1W`= zd(dC9o1gtw^;wnGkzIX*D3&RJlu{;EZtDSYgRLnYE9AnJF5D&`stm2K(ito46<@56rOuwPbEOg7QK z@eWU~z_}FFsuBOqHqXM?OYqR2!vFbA_{VR)ju?2gq1OeV21jph>Xuz)0%>6n-W_ zfMWJi;q(h9P>cUTgUM0>0r9&q@H74}$LX?DRQuyObZYe!FUr1>Vt>cCn|f__s;OUp)a4081$fj%>Ox_EZqu%h8rBY#|q<%LEP zkO$yvJK^UNuw4ZL9W$5MF|d~-JvU-!PHXedy-83YnQ89g)aJX`PzC_6Lqk-##My`{ zGV4)Q3XJ6u> z;xAH6q^f>VG{+!o6mMmqo=7sV{#J6yCep>MO9}{@2B=&Qvpxm%n*0S*TvS%mxyjE=(DemHRuPCW*FhhQvUncJI}wV2is z+{*l3%ejN5sj-auR;n2QT7LGE9SH4#N@ocp1xTb23enOFFv6MyK^A643IlrwvCsd# zwwYUs;=i>~Bp|;HQziJz--BWgye|RWRz1L~@w03>_bTVRHW&l4@}mWW6HW?HQaqDu7^<0z6HE9;1kATA}N* zc=5y$S<3sF|CT!QTN@fnJLu;i_Z#rYHhB5XaOJgdT_eF|LMaEjUg;lJvi22~zlBrWcTP7|%>po6jDl==;&Dw+%Q|t&_$I2 ze?6)$9kxMo_fy9>5Vt7xTY+Z z>bcXC&+5|a<=o>O_3J(`RV@MR07SCsI$)jq!}%x!tx`bDCP97t?9tL_-z5GB9hdTW zF^~!XOr4ocQ7B9KVsYfeE9uqOJb=)W!P$BX@v)l(k)&5$y)N0YE?FEOn8dwS-nt=% zpXjU_ae_vD>*(*Cr(63}N%32i#+kROtaH{ct6mFmB-554k-7_uZCTyqLEnN<;ZpI~ zCI!e+v3TL|QS9?7rwFzb-G2SZ*?FxapmnsH2Bx#eAKfR5Gw1Bd?e}gepqhRwy>e8> zLqei;h}(exvoYlH4=aTXnE_$ zRtVT_6kBbUtG7wey82HRrbi2BUOa`r`A@Z^e5nF~(;R5F3Ajf)9Vq4cp6=7LV}}x{ z*7eGqqP#p-DL^7p&8`l}=Wb86|O6HvwX>TmNh z;ybA-3E(;@rPkE310k+dmOV%c2z9uhJ3rsCB713|bm7P-?)m@aWd8GP9^6uqxe_f) zc{D3c42~4X&OT%RKweUSSO2Z1Swo}|g3S6`w`ncQ1>q%tDaHMi$8+b1Zk@-^Sl;2> zlH2LG?>v>)OUbt&2B4|{;95W=(suwIvw@zXz8_Tp!QQC?rf#ljfTT3icSN5aF?at_ zNdT84^Q%TVudUH$fSJP3YcEQ@IP95I*zmx}9uoksX@!JD+v=W7?``eaMQdvvY*QMK zd+T54y-IQC6ziQ#uUfDZcHTelR22Yxmkh#>>TywEYo_Yb=^1~*#SZ7 zg_*+nSC8ZG{(HHY{?tw87F$(2&}4I`o;pr3cRVP&Fj9d3k>a5OZO^q#`-;|U-@XI2 zi>-4P9j~?Zsx7uuj2>ono)^;xDft!*MFjw{T}r8Stl1{vR_n)6{|!Sim;`f^L&Y2DA@B>^i4*cPMA9RB_VJwNq=*C_0pdzHlsZ4wlkNUyx=3N6!-#I4*6 zv)^OR=b3XQp4aH^H_9(aG2f3OO8_ZABvQM8WiD||EzcpnMsmkg)2(5)-gpBDt+g7(+{kAJm+Y^E;Kt-QtevIxE=~cRwMM;q) zfM1YOCO7WhP}%%Lx<9#xKxVLLZCa+>p0`-ujb?)*meMLEj*Q6vC(1|X5X z5?E`7)hq?nViFXfPmlB!&+flKL;SCXoE=&R^!(Yq(|UIN34FPK{!D_&uHLItE4H^$ z+27Q<->FI}2+Pb%n%Pwp2>?zCAw(j%8)yn+0f@yU=mH2!-cq8E_8*lqm$KLaAV~ag zkejR^Ad6!Id(riw$|FZiuN)%1&ioU_Bq-4`?Q5F0-q%9~089|DN(0t9T{a_nR0<0K z0-)*s54D2039yOhb`eh{1=L^?tYqR2(TmyQ&}%0MUZHilYX8HMiy5*f9yuxX;xTnp z`+ZF&LC@lNlSri7(`&95O*lPTy-k7wnV%jmo_+Z& z?$O_G7wHD`d(&+DOg#0eOR_NYOytpiE=+<#OQcs_wFAQ_mB0-G4Il_fp#h*DAep&B zDmVX7DFYR8h}R^jPY#_Z4jsCLTLI_?0uAyfvn`9IsSD47oU8;}qj)9t$vBcS&^<+% zB>{=HRhyE_x3*z0eG)qe2;2(71}P{s0Bi?@NL&ZB7$MS!-xVWw)h0nNDL~gt7mpoA zmYl2qE_e6eTF53P6^0JKhF&`38@p~MLBD7cEz_|!z53d2+az`@>s`HY?{6z8Gt9|&7{;{5-vc$=$31{+!zZ;aL{C@X$tl=fG zm;@yx({1ThSFOZf4`6~o0}MPUGyo))Z%G4dj9*Tsw}-mos3U|h33^Eh0-2v2E}lC$ zjQ9LswaZF_{K*Ca`GLJzJv;U!m1V?=sFnM$6ch#k1c1o2 zrGZuID1H`z5OG{jDx-p33Ml^)5`BE|h(314Z}*?e=>U*8$>tYO96PfgSsM0~0_Mvk zD1^|OySs6V{*7rs1PUzy2uL8vm|Q8nq<}z^p!vsb614yJp;9qdJon0LxIMkR4p3}R zbkYH`#~wZ*_2N-=l=(FYid0J*h62!!@f*_s9}4XUpywuwNIB*mWv)zupwEn+$RGaU zVJhOkb6Mu)w);=qG}}%S&wTol%uhXP5A}*2GCbt#gObbq)O0zTlldDufRGeg0w}!l z?P+A`1>=`f3b2w>87SRf(&-pvPwj6K)b(O8!7+<~t@k9#Sr-!aM!3QsPHY2;hKeL&BSKZsvj`kDlN1N<^n#}Ugqi zV~a?pHzBkp^x%I`u!V&fDc6h z06}g-=%f9IMEjbTL?TrZBGHB*B@hCEhR_T&5XQ$6Ud%uHyBtqg!=ywa(MwtM!nDke zpDqm_douspcfX!}@t>a1#|BK%U$rr$x#No|2{fdD>c}ZdYuOWzO=nL&{&b>!&C4R0 znL!8*A(Fz1*v3`){z6#3GgO{o?;}EWnZl`*=z5V-Zc3jXIh{Yd@A0vxKKbCp^Pf9{ zoB2)fx9$+!Pz3u>7!he!0>IF?rht^_Slg9cd))@Dt9OfN?%E(ynUy3m-6D}{MN77T zNQhLX&G?eD3~qU!kg}AYLY9gGAt)8HvXq~Z`ROTH%w^@+z>q$9eoVi3aueoMTvU~ID)XFP2Yt5a@M5eu4 zq?+4=mPm_KOPe{Z6UnA#kw`a{PlN$!8{%gNcBz*NvM@7U-U?oqg{etdn91t-shQH` z(51rQ{=xjYS4RsM4vk5@XzF>aeZacQuXQkJ^Z_4=0stBn0LWl;h9;o7d^G`Sv_umk znMR0&$h4+Gvxotb^5-^w7N(1`R5ZGTUMiuNN*-k}Kc4l&n~1O4wpk?tHa>ndvI2hs z0{~1AFu%8Xr7`+O5|K!XL@I5j7wOi7NHr%aiag8rFTX_~kfprTa}z~V3oJ{yB2sA+ zwpxD7t<9ag`#J}KMj!B?s0V{s-EN(S9u@$BUII!*K$FGnG!;c-7_F>Ibv&z$#=eoI zRs1(aK(7;k8y~)UvVyPK_D;{cN<3LA$zsXq3m{}MtNQL?m2)avuv7V~I)SHzx-T`z zL16)4)-w$E2e5VklR>S{8Ga@i^W+-myR7fDzT0ZkZXE>toWL6WK|x^vK%zck%IpXxS()Di0D;{zt6Bh1?e}ln z&KmoizcJ^FkDTA#9NobV00k-lVEyhvHhJOkPUribeZgxh@P-*U0@X4FaBjwEGG>%T z8P!MtP7rV&$J9Xnc*)uR=Fwc%a1;2wmgL4E0eDG+UiuPI{O!m5 zQFH)K5YSPBK+p=n#>cOXtkNF@e|JCNkE#Rk>I+_jfu9ptqaS!sHKzlcAHezB>#g* knO|5`iqaPv{U9X$|BhIt=$N&HU07*qoM6N<$g5sK|lmGw# diff --git a/activity_browser/static/icons/main/star.png b/activity_browser/static/icons/main/star.png deleted file mode 100644 index 3269055490ae08c4c768ae044aa57c6cf279d66a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1187 zcmV;U1YG-xP)7uw5>AqNZE(+~NNWq0l4Q6AZLb13A5-7CLg`}3`wQ2lQBN2&BlbkN* zzVJxzS zH3gJX%|HbZpi4wnecYqJFR z2e~#w7Zs4S`Gu_vyqhtW4dy}sn}(KqGt6}gD5bi9TbK*I3UrFdLUySBZ+TZh+U6Iw zX5dIhSRyS|1t_Ijfo;IE`fr?9zaIdew#2vuTmu$E{&&Q&MPN}xmJ@RV-X>Umg~3Pp zR!cIj>^|`V)gG@vaFwdNvzy5)zyNv*>;$F@0Lqn51MMOLB2pEh75KIQ;PUnhKNW0)G_%ysh5@-i+lL_k|W= z7cf%*5~ltO@KRll^}f_1YzM9sfJ|Dy3_RD6U!xPW2wQ>i0+34TUjSPYaZ7SijSwdZ zp5yr@80YK2$Ei4_Ia!PF0kB*EvSI%hIFy=C+5uXGy}&{NNLPIxc+H55$sjF48Ms~m z@>hWujCq(2)FM0seD9I`cfeCt9Bc+_5t@Lr9>{+Vlx*=^?+8ioJpYPq90U7NGT1TB zYD?^`jN&-YnUw&88O3p)(|T7}@BBJ{xk=meBJ#UUfGjG&NBL)#`-uQW0O2#YRS!@~ z^%A`B=+ipH;xW2y1^B%BRkOnIL;#P;mw`{T|7Q`$ZarXe53mdKb^IluuO`-uzz-Jj z%Fb_wfa3;0{{V*T#2g0xG>H454*|YP33420OAxmWIG;AQ4*`Bl2yO=0XAu7#f}hwV zIp&-S0KAd_)CzD4Xt5Bv1vm{nNRY43D2E}i6Ai#j0k4`8kI1(h__;yOQI`T-hyk&r z#j)P0iO5+3J_7!U$^E4h0SMkIxfcOof?!#iDe;K>oxo&7{&`md41^Nj1`g&;JOamI z;BE*@f7&=EE5YjOZyh)TyeuLU263!RM7{y~fpY}pYq6yYlvepkg69FRyT0mmFueiX z0KRrn0T+QiA~Iz#HWw3-p9rSr<+NkH{qJy5>>+6D QtWidgets.QMainWindow: - """Returns the main_window widget of the Activity Browser""" - if self._main_window: - return self._main_window - raise Exception( - "main_window not yet initialized, did you try to access it during startup?" - ) - - @main_window.setter - def main_window(self, widget: QtWidgets.QMainWindow): - self._main_window = widget - - # connect global keyboard shortcuts to their respective functions - for seq, func in _global_shortcuts.items(): - shortcut = QtWidgets.QShortcut(QtGui.QKeySequence(seq), widget) - shortcut.activated.connect(func) - - def show(self): - self.main_window.showMaximized() - - def close(self): - for child in self.children(): - if hasattr(child, "close"): - child.close() - - def deleteLater(self): - self.main_window.deleteLater() - - -def global_shortcut(key_sequence): - """ - Decorator to register a global keyboard shortcut for the main window. Decorate a function with e.g. - @global_shortcut("Ctrl+S") to register it as a shortcut. Also works on the run method of actions as long as the - parameters of said action are taken care of. - """ - def decorator(func): - _global_shortcuts[key_sequence] = func - return func - return decorator - -_global_shortcuts = {} diff --git a/activity_browser/ui/core/threading.py b/activity_browser/ui/core/threading.py index d2ce05b23..6de0f0818 100644 --- a/activity_browser/ui/core/threading.py +++ b/activity_browser/ui/core/threading.py @@ -1,5 +1,5 @@ import threading -from loguru import logger +import logging from qtpy.QtCore import QThread, SignalInstance, Signal from qtpy import QtWidgets @@ -15,8 +15,8 @@ class ABThread(QThread): def __init__(self, parent=None): super().__init__(parent) - from activity_browser import app - self.exception.connect(app.main_window.dialog_on_exception) + from activity_browser import application + self.exception.connect(application.main_window.dialog_on_exception) def start(self, *args, priority=QThread.NormalPriority, **kwargs): """ @@ -84,38 +84,30 @@ def __exit__(self, *args): class InfoToSlot: def __init__(self, progress_slot=lambda progress, message: None): - self.sink = LoggingProgressSink("INFO") + self.handler = LoggingProgressHandler("INFO") thread_local.progress_slot = progress_slot - self._sink_id = None def __enter__(self): - # Attach a loguru sink which forwards INFO logs from this thread to the progress slot - self._sink_id = logger.add(self.sink, level="INFO") + logging.root.addHandler(self.handler) return def __exit__(self, *args): - if self._sink_id is not None: - try: - logger.remove(self._sink_id) - except Exception: - pass + logging.root.removeHandler(self.handler) return -class LoggingProgressSink: - def __init__(self, level="INFO"): - self.level = level - def __call__(self, message): - record = message.record +class LoggingProgressHandler(logging.Handler): + def filter(self, record: logging.LogRecord) -> bool: + if record.thread != threading.get_ident(): + return False + if record.levelname != "INFO": + return False + return True + + def emit(self, record: logging.LogRecord): try: - # Only handle messages from the current thread and matching level - if record["level"].name != self.level: - return - if record["thread"].id != threading.get_ident(): - return - thread_local.progress_slot(None, record.get("message", "")) + thread_local.progress_slot(None, record.message) except AttributeError: - # No progress slot set or malformed record pass diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py deleted file mode 100644 index 86a020446..000000000 --- a/activity_browser/ui/core/tree_model.py +++ /dev/null @@ -1,585 +0,0 @@ -from typing import Optional -from loguru import logger - -import pandas as pd - -from PySide6 import QtGui -from PySide6.QtCore import QModelIndex, Qt, QAbstractItemModel -from PySide6.QtWidgets import QWidget - -from activity_browser.ui.icons import qicons - - -class TreeNode: - """ - Optimized node object that combines children_map, row_indices, loaded_counts, - and DataFrame position for O(1) lookups. - """ - __slots__ = ('path', 'children', 'row_in_parent', 'loaded_count', 'df_position', 'is_leaf', '_child_lookup') - - def __init__(self, path: tuple, df_position: int = -1): - self.path: tuple = path # Full path tuple for this node - self.children: list['TreeNode'] = [] # List of child nodes - self.row_in_parent: int = -1 # Row index within parent's children list - self.loaded_count: int = 0 # Number of children currently loaded (for lazy loading) - self.df_position: int = df_position # Integer position in DataFrame (-1 for branch nodes) - self.is_leaf: bool = (df_position >= 0) # True if this is a leaf node - self._child_lookup: dict[tuple, TreeNode] = {} # Fast child lookup by path - - def add_child(self, child: 'TreeNode') -> None: - """Add a child node and update its row_in_parent.""" - child.row_in_parent = len(self.children) - self.children.append(child) - self._child_lookup[child.path] = child - - def get_child(self, path: tuple) -> Optional['TreeNode']: - """Get a child by its path (O(1) lookup).""" - return self._child_lookup.get(path) - - def get_child_at(self, row: int) -> Optional['TreeNode']: - """Get a child by its row index (O(1) lookup).""" - if 0 <= row < len(self.children): - return self.children[row] - return None - - def total_children(self) -> int: - """Total number of children (for lazy loading comparison).""" - return len(self.children) - - def can_fetch_more(self) -> bool: - """Check if more children can be loaded.""" - return self.loaded_count < len(self.children) - - -class ABTreeModel(QAbstractItemModel): - def __init__(self, - df: pd.DataFrame = None, - parent: Optional[QWidget] = None, - chunk_size: int = -1, - enable_sorting: bool = False - ) -> None: - super().__init__(parent) - self.df = df if df is not None else pd.DataFrame() - self.df.index = pd.MultiIndex.from_arrays([range(len(self.df))], names=[f"index"]) - - self.df_query: dict[str, str] = {"model": "index == index"} # dictionary where queries can be registered - self.filtered_columns: set[int] = set() # set of column indices that have active filters, only used for the header icon - self.grouped_columns: list[str] = [] # list of columns currently used for grouping - - self.sorted_column: str | None = None - self.sort_order = Qt.SortOrder.AscendingOrder - self.sorting_enabled = enable_sorting - - self.lazy = chunk_size > 0 - self.chunk_size = chunk_size - - # Single unified node map: path -> TreeNode - self.node_map: dict[tuple, TreeNode] = {} - self.root: TreeNode = TreeNode(tuple()) # Root node with empty path - - # Build the node hierarchy - self.build_node_hierarchy(self.df.index) - - def columns(self) -> list[str]: - """Return the list of column names, including the tree column.""" - return ["index"] + [col for col in self.df.columns if not col.startswith("_")] - - def column_name(self, index: QModelIndex) -> str: - """Return the name of the column at the given index, including the tree column.""" - return self.columns()[index.column()] - - def row(self, index: QModelIndex) -> pd.Series | None: - """ - Return the DataFrame row corresponding to the given index, or None for non-leaf nodes. - - Warning: This is a slow operation and should be avoided in methods called frequently like data(), *Data(), flags(), or index*(). - """ - if not index.isValid(): - return None - - node = index.internalPointer() - - if not isinstance(node, TreeNode) or not node.is_leaf: - return None - - # Use the pre-computed df_position for fast access - return self.df.iloc[node.df_position] - - def get(self, index: QModelIndex, column: str | int) -> any: - """ - Get the data for the given QModelIndex and column name or index. - """ - if not index.isValid(): - return None - - node = index.internalPointer() - - if not isinstance(node, TreeNode) or not node.is_leaf: - return None - - column_i = column if isinstance(column, int) else self.df.columns.get_loc(column) - - return self.df.iat[node.df_position, column_i] - - - # --- required model overrides --- - def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex: - parent_node = parent.internalPointer() if parent.isValid() else self.root - - if not isinstance(parent_node, TreeNode): - parent_node = self.root - - child_node = parent_node.get_child_at(row) - - if child_node is None: - return QModelIndex() - - return self.createIndex(row, column, child_node) - - def parent(self, index: QModelIndex) -> QModelIndex: - if not index.isValid(): - return QModelIndex() - - node = index.internalPointer() - if not isinstance(node, TreeNode): - return QModelIndex() - - parent_path = self.parent_path(node.path) - - if len(parent_path) == 0: - return QModelIndex() - - parent_node = self.node_map.get(parent_path) - if parent_node is None: - return QModelIndex() - - return self.createIndex(parent_node.row_in_parent, 0, parent_node) - - def parent_path(self, path: tuple) -> tuple: - path = tuple(val for val in path if not pd.isna(val)) - return path[:-1] - - def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: - # For tree models, when the parent is valid and column > 0, return 0 - if parent.isValid() and parent.column() != 0: - return 0 - - parent_node = parent.internalPointer() if parent.isValid() else self.root - - if not isinstance(parent_node, TreeNode): - parent_node = self.root - - # Return the number of currently loaded children - return parent_node.loaded_count - - def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802 (Qt signature) - # Always return the full column count for consistent tree structure - return len(self.columns()) - - #--- data overrides --- - def data(self, index: QModelIndex, role: int = Qt.DisplayRole): - # if not index.isValid() or self.df.empty: - # return None - - if role == Qt.DisplayRole: - return self.displayData(index) - elif role == Qt.EditRole: - return self.editData(index) - elif role == Qt.UserRole: - return self.userData(index) - elif role == Qt.DecorationRole: - return self.decorationData(index) - elif role == Qt.FontRole: - return self.fontData(index) - elif role == Qt.ToolTipRole: - return self.toolTipData(index) - - return None - - def displayData(self, index: QModelIndex) -> any: - node = index.internalPointer() - - if not isinstance(node, TreeNode): - return None - - if not node.is_leaf: # branch node - # For branch nodes, show the name in the first column only - # (spanning will be handled by the view) - return node.path[-1] if index.column() == 0 else None - - if index.column() == 0: - return None # leaf node tree column is empty - - # Get the pandas column index (disregard hidden columns) - col_name = self.columns()[index.column()] - col_idx = self.df.columns.get_loc(col_name) - - val = self.df.iat[node.df_position, col_idx] - - if not hasattr(val, "__iter__") and pd.isna(val): - return None - - return val - - def editData(self, index: QModelIndex) -> any: - return self.displayData(index) - - def userData(self, index: QModelIndex) -> any: - return self.displayData(index) - - def decorationData(self, index: QModelIndex) -> any: - return None - - def fontData(self, index: QModelIndex) -> any: - return None - - def toolTipData(self, index: QModelIndex) -> any: - return None - - #--- flag overrides --- - def flags(self, index): - if not index.isValid(): - return Qt.ItemFlag.NoItemFlags - - flags = Qt.ItemFlag.NoItemFlags - if self.indexEnabled(index): - flags |= Qt.ItemFlag.ItemIsEnabled - if self.indexSelectable(index): - flags |= Qt.ItemFlag.ItemIsSelectable - if self.indexEditable(index): - flags |= Qt.ItemFlag.ItemIsEditable - if self.indexDragEnabled(index): - flags |= Qt.ItemFlag.ItemIsDragEnabled - if self.indexDropEnabled(index): - flags |= Qt.ItemFlag.ItemIsDropEnabled - if self.indexUserCheckable(index): - flags |= Qt.ItemFlag.ItemIsUserCheckable - return flags - - def indexEnabled(self, index: QModelIndex) -> bool: - return True - - def indexSelectable(self, index: QModelIndex) -> bool: - return True - - def indexEditable(self, index: QModelIndex) -> bool: - return False - - def indexDragEnabled(self, index: QModelIndex) -> bool: - return False - - def indexDropEnabled(self, index: QModelIndex) -> bool: - return False - - def indexUserCheckable(self, index: QModelIndex) -> bool: - return False - - def isBranchNode(self, index: QModelIndex) -> bool: - """Check if the given index represents a branch node (non-leaf).""" - if not index.isValid(): - return False - node = index.internalPointer() - if not isinstance(node, TreeNode): - return False - return not node.is_leaf - - def headerData(self, section: int, orientation: Qt.Orientation = Qt.Horizontal, role: int = Qt.DisplayRole): - if orientation == Qt.Vertical: - return None - - if role == Qt.DisplayRole: - if section == 0: - return "" - - return self.columns()[section] - - if role == Qt.ItemDataRole.FontRole and section in self.filtered_columns: - font = QtGui.QFont() - font.setUnderline(True) - return font - - if role == Qt.ItemDataRole.DecorationRole and section in self.filtered_columns: - return qicons.filter - - def canFetchMore(self, parent: QModelIndex) -> bool: - """Check if this parent has more children that can be loaded.""" - if not self.lazy: - return False - - parent_node = parent.internalPointer() if parent.isValid() else self.root - - if not isinstance(parent_node, TreeNode): - parent_node = self.root - - return parent_node.can_fetch_more() - - def fetchMore(self, parent: QModelIndex) -> None: - """Load the next chunk of children when user scrolls.""" - if not self.lazy: - return - - parent_node = parent.internalPointer() if parent.isValid() else self.root - - if not isinstance(parent_node, TreeNode): - parent_node = self.root - - total_children = parent_node.total_children() - currently_loaded = parent_node.loaded_count - - if currently_loaded >= total_children: - return # Everything already loaded - - # Calculate how many more to load - remaining = total_children - currently_loaded - to_load = min(self.chunk_size, remaining) - - # Notify view that we're about to add rows - first_new_row = currently_loaded - last_new_row = currently_loaded + to_load - 1 - - self.beginInsertRows(parent, first_new_row, last_new_row) - parent_node.loaded_count = currently_loaded + to_load - self.endInsertRows() - - # --- helper functions --- - def set_dataframe(self, df: pd.DataFrame, group: list[str] = None) -> None: - self.beginResetModel() - - self.df = df - self.grouped_columns = group or self.grouped_columns - - self.build_df_index() - self.apply_sort() - self.apply_filter() - - self.endResetModel() - - def update_dataframe(self, df: pd.DataFrame, group: list[str] = None) -> None: - self.layoutAboutToBeChanged.emit() - self.df = df - self.grouped_columns = group or self.grouped_columns - - self.build_df_index() - self.apply_sort() - self.apply_filter() - - self.layoutChanged.emit() - - def group(self, columns: list[str] = None) -> None: - self.layoutAboutToBeChanged.emit() - self.grouped_columns = columns or self.grouped_columns - - self.build_df_index() - self.apply_sort() - self.apply_filter() - - self.layoutChanged.emit() - - def ungroup(self) -> None: - self.layoutAboutToBeChanged.emit() - self.grouped_columns = [] - - self.build_df_index() - self.apply_sort() - self.apply_filter() - - self.layoutChanged.emit() - - def sort(self, column: int | str, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: - if not self.sorting_enabled: - logger.warning(f"Called sort() on {self.__class__.__name__} with sorting disabled.") - return - - self.layoutAboutToBeChanged.emit() - - self.sorted_column = self.headerData(column) if isinstance(column, int) else column - self.sort_order = order - - self.apply_sort() - self.apply_filter() - - self.layoutChanged.emit() - - def filter(self, key: str = None, query: str = None) -> None: - """Filter the DataFrame based on a simple substring match across all columns.""" - self.layoutAboutToBeChanged.emit() - - if query is not None and key is not None: - self.df_query[key] = query - - self.apply_filter() - - self.layoutChanged.emit() - - def build_df_index(self): - # dataframe we will use to build the new index - df = self.df[self.grouped_columns].copy() - - # unpack iterables in the grouped columns - for col in self.grouped_columns: - # Check if the column contains iterables (excluding strings) - sample_val = df[col].dropna().iloc[0] if not df[col].dropna().empty else None - if not isinstance(sample_val, (list, tuple, set)): - continue - - # Unpack the iterable into separate columns and add to the dataframe - unpacked = pd.DataFrame(df[col].tolist(), index=df.index) - for i, unpacked_col in enumerate(unpacked.columns): - df[f"{col}_{i}"] = unpacked[unpacked_col] - - # Remove the original column from the dataframe - df = df.drop(columns=[col]) - - df = df.dropna(how='all', axis=1) - df["index"] = range(len(df)) - - new_index = pd.MultiIndex.from_frame(df) - new_index.names = [i + "_i" for i in new_index.names] - - self.df.index = new_index - - def reset_hierarchy(self, df: pd.DataFrame = None) -> None: - df = df if df is not None else self.df - old_persistent_indices = [(idx, idx.internalPointer()) for idx in self.persistentIndexList()] - - # Rebuild the node hierarchy - self.build_node_hierarchy(df.index) - - # Update persistent indexes - new_persistent = [] - for old_index, old_node in old_persistent_indices: - if isinstance(old_node, TreeNode): - # Try to find the same path in the new hierarchy - new_node = self.node_map.get(old_node.path) - if new_node is not None: - new_index = self.createIndex(new_node.row_in_parent, old_index.column(), new_node) - new_persistent.append(new_index) - else: - new_persistent.append(QModelIndex()) - else: - new_persistent.append(QModelIndex()) - - # Update the model's persistent indexes - self.changePersistentIndexList(self.persistentIndexList(), new_persistent) - - def build_node_hierarchy(self, pandas_index: pd.Index) -> None: - """ - Build the unified TreeNode hierarchy with all information combined: - - children relationships - - row indices - - loaded counts - - DataFrame positions - """ - self.root = TreeNode(tuple()) - self.node_map = {tuple(): self.root} - - # Convert index to frame once for all operations - idx_df = pandas_index.to_frame(index=False) - - # Create a mapping from full path to DataFrame position - path_to_position = {} - for row_tuple in idx_df.itertuples(index=False, name=None): - df_pos = self.df.index.get_loc(row_tuple) - path_to_position[row_tuple] = df_pos - - # Process each level to build the hierarchy - for level in range(idx_df.shape[1]): - # Get unique child paths at this level (as tuples) - child_paths = idx_df.iloc[:, :level + 1].drop_duplicates() - child_tuples = list(child_paths.itertuples(index=False, name=None)) - - for child_path in child_tuples: - if pd.isna(child_path[-1]): - continue # skip NaN children - - # Skip if we've already created this node - if child_path in self.node_map: - continue - - # Determine parent path - if level == 0: - parent_path = tuple() - else: - parent_path = tuple(val for val in child_path[:-1] if not pd.isna(val)) - - # Get or create parent node - parent_node = self.node_map.get(parent_path) - if parent_node is None: - parent_node = self.root - - # Check if this is a leaf node (full depth) - is_leaf = (level == idx_df.shape[1] - 1) - df_position = path_to_position.get(child_path, -1) if is_leaf else -1 - - # Create the child node - child_node = TreeNode(child_path, df_position) - - # Add child to parent - parent_node.add_child(child_node) - - # Store in node map - self.node_map[child_path] = child_node - - # Initialize loaded counts - if self.lazy: - # Load first chunk for each node - for node in self.node_map.values(): - node.loaded_count = min(self.chunk_size, node.total_children()) - else: - # All children loaded - for node in self.node_map.values(): - node.loaded_count = node.total_children() - - def apply_filter(self): - pandas_query = " & ".join(self.df_query.values()) - filtered_df = self.df.query(pandas_query) - self.reset_hierarchy(filtered_df) - - def apply_sort(self): - if self.df.empty or not self.sorting_enabled: - return - - logger.debug(f"Applying sorting in : {self.__class__.__name__}") - - # Extract the unique order of higher levels - higher_levels = self.df.index.droplevel(-1).unique() if self.df.index.nlevels > 1 else [None] - - # Build a new index by sorting only within each higher level - sorted_index = [] - - for lvl in higher_levels: - mask = self.df.index.droplevel(-1) == lvl if lvl is not None else self.df.index - partial_df = self.df.loc[mask, self.sorted_column or self.df.columns[0]].copy() - if self.sorted_column is not None: - partial_df.sort_values(ascending=(self.sort_order == Qt.SortOrder.AscendingOrder), inplace=True) - else: - partial_df = partial_df.sort_index(ascending=(self.sort_order == Qt.SortOrder.AscendingOrder)) - sorted_index.append(partial_df.index) - - sorted_index = sorted_index[0].append(sorted_index[1:]) # Flatten - self.df = self.df.loc[sorted_index] # Update dataframe to new sorted order - - def values_from_indices(self, key: str, indices: list[QModelIndex]): - """ - Returns the values from the given indices. - - Args: - key (str): The key to get the values for. - indices (list[QtCore.QModelIndex]): The indices to get the values for. - - Returns: - list: The list of values. - """ - df_positions = [] - for index in indices: - if not index.isValid(): - continue - node = index.internalPointer() - if isinstance(node, TreeNode) and node.is_leaf: - df_positions.append(node.df_position) - - if not df_positions: - return [] - - return self.df.iloc[df_positions][key].tolist() - diff --git a/activity_browser/ui/delegates/README.md b/activity_browser/ui/delegates/README.md deleted file mode 100644 index 099aaa61e..000000000 --- a/activity_browser/ui/delegates/README.md +++ /dev/null @@ -1,138 +0,0 @@ -# delegates - -Qt item delegates for custom cell rendering and editing in tables and trees. - -## Overview - -This directory contains custom Qt delegates that control how data is displayed and edited in table and tree views throughout Activity Browser. Delegates enable specialized rendering, validation, and editing behavior for different data types. - -## What are Delegates? - -In Qt's Model/View architecture, delegates handle: -- **Display** - How data appears in cells (colors, icons, formatting) -- **Editing** - What widget appears when user edits a cell -- **Validation** - Checking user input before accepting -- **Decoration** - Adding icons, colors, or other visual elements - -## Usage Pattern - -Assign delegates to specific columns by defining them in the `defaultColumnDelegates` attribute of the `ABTreeView` class: - -```python -class View(widgets.ABTreeView): - """ - A view that displays the exchanges in a tree structure. - - Attributes: - defaultColumnDelegates (dict): The default column delegates for the view. - hovered_item (ExchangesItem): The item currently being hovered over. - """ - defaultColumnDelegates = { - "column_name": delegates.DelegateYouWantToUse, - } -``` - - -## Creating Custom Delegates - -Inherit from `QStyledItemDelegate`: - -```python -from qtpy.QtWidgets import QStyledItemDelegate, QLineEdit - -class MyDelegate(QStyledItemDelegate): - def createEditor(self, parent, option, index): - """Create the editing widget.""" - editor = QLineEdit(parent) - editor.setValidator(...) # Add validation - return editor - - def setEditorData(self, editor, index): - """Load data into editor.""" - value = index.data(Qt.EditRole) - editor.setText(str(value)) - - def setModelData(self, editor, model, index): - """Save editor data back to model.""" - value = editor.text() - model.setData(index, value, Qt.EditRole) - - def displayText(self, value, locale): - """Format value for display.""" - return f"{value:.2f}" -``` - -## Key Methods - -### `createEditor(parent, option, index)` -Creates the widget used for editing: -- **parent** - Parent widget for the editor -- **option** - Style options for the item -- **index** - Model index being edited -- **Returns** - Editor widget (QLineEdit, QComboBox, etc.) - -### `setEditorData(editor, index)` -Populates the editor with current value: -- **editor** - The editor widget -- **index** - Model index with data - -### `setModelData(editor, model, index)` -Saves edited value back to model: -- **editor** - The editor widget -- **model** - The data model -- **index** - Model index to update - -### `displayText(value, locale)` -Formats value for display (optional): -- **value** - Raw data value -- **locale** - Locale for formatting -- **Returns** - Formatted string - -### `paint(painter, option, index)` -Custom rendering (advanced): -- **painter** - QPainter for drawing -- **option** - Style options -- **index** - Model index to render - -## Validation - -Add validators to editors: - -```python -def createEditor(self, parent, option, index): - editor = QLineEdit(parent) - validator = QDoubleValidator(0.0, 1000.0, 2, editor) - editor.setValidator(validator) - return editor -``` - -## Signal Handling - -Delegates can emit signals on edits: - -```python -from qtpy.QtCore import Signal - -class MyDelegate(QStyledItemDelegate): - editingFinished = Signal(QModelIndex, object) - - def setModelData(self, editor, model, index): - value = editor.text() - model.setData(index, value) - self.editingFinished.emit(index, value) -``` - -## Development Guidelines - -When creating delegates: - -1. **Inherit from QStyledItemDelegate** - Preferred over QItemDelegate -2. **Validate input** - Add QValidator to editors -3. **Handle edge cases** - Empty values, invalid data, cancellation -4. **Match data types** - Editor should match model data type -5. **Close editor properly** - Emit closeEditor signal when done -6. **Keep it simple** - Complex editing might need a dialog -7. **Test thoroughly** - Verify editing, validation, display -8. **Consider performance** - Efficient for many cells -9. **Support keyboard** - Tab, Enter, Escape navigation -10. **Provide feedback** - Visual cues for invalid input diff --git a/activity_browser/ui/delegates/__init__.py b/activity_browser/ui/delegates/__init__.py index c80635da9..6ff8fd045 100644 --- a/activity_browser/ui/delegates/__init__.py +++ b/activity_browser/ui/delegates/__init__.py @@ -1,8 +1,10 @@ # -*- coding: utf-8 -*- from .checkbox import CheckboxDelegate from .combobox import ComboBoxDelegate +from .database import DatabaseDelegate from .delete_button import DeleteButtonDelegate from .float import FloatDelegate +from .formula import FormulaDelegate from .json import JSONDelegate from .list import ListDelegate from .string import StringDelegate @@ -13,15 +15,16 @@ from .date_time import DateTimeDelegate from .property import PropertyDelegate from .amount import AmountDelegate, AbsoluteAmountDelegate -from .card import CardDelegate __all__ = [ "AmountDelegate", "AbsoluteAmountDelegate", "CheckboxDelegate", "ComboBoxDelegate", + "DatabaseDelegate", "DeleteButtonDelegate", "FloatDelegate", + "FormulaDelegate", "JSONDelegate", "ListDelegate", "StringDelegate", @@ -32,5 +35,4 @@ "NewFormulaDelegate", "DateTimeDelegate", "PropertyDelegate", - "CardDelegate", ] diff --git a/activity_browser/ui/delegates/card.py b/activity_browser/ui/delegates/card.py deleted file mode 100644 index 58218e422..000000000 --- a/activity_browser/ui/delegates/card.py +++ /dev/null @@ -1,192 +0,0 @@ -from typing import TypedDict - -from qtpy import QtCore, QtWidgets, QtGui -from qtpy.QtCore import Qt - - -class CardData(TypedDict): - title: str - subtitle: str | None - detail: str | None - categories: list[str] | None - - -class CardDelegate(QtWidgets.QStyledItemDelegate): - """Delegate for rendering card-like items with title, subtitle, categories and background icon.""" - - PADDING = 8 - MARGIN = 2 - TITLE_LINES = 2 - ICON_OPACITY = 0.3 - - def sizeHint(self, option, index): - if index.data() is None: - return super().sizeHint(option, index) - - # Calculate text heights - fm = option.fontMetrics - line_height = fm.height() - - # Title (2 lines, larger font) - title_height = int(line_height * 1 * self.TITLE_LINES) + 5 - - # Subtitle - subtitle_height = int(line_height * 0.9) # 0.9x for smaller font - - # Categories - categories_height = 7 + int(line_height * 0.8) - - # Total height with padding - total_height = (self.PADDING * 2 + self.MARGIN * 2 + - title_height + subtitle_height + categories_height) - - return QtCore.QSize(option.rect.width(), max(total_height, 40)) - - def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): - if index.data() is None: - super().paint(painter, option, index) - return - - painter.save() - painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) - - card_data = index.data() - is_selected = option.state & QtWidgets.QStyle.StateFlag.State_Selected - font_size = option.font.pointSize() - - # Draw background and border - rect = option.rect.adjusted(self.MARGIN, self.MARGIN, -self.MARGIN, -self.MARGIN) - - # Background - painter.fillRect(rect, option.palette.base()) - - # Border - border_color = option.palette.highlight() if is_selected else option.palette.mid() - painter.setPen(QtGui.QPen(border_color, 1)) - painter.drawRoundedRect(rect, 3, 3) - - # Draw background icon - icon = index.data(Qt.ItemDataRole.DecorationRole) - icon_size = 0 - if icon and not icon.isNull(): - painter.setOpacity(self.ICON_OPACITY) - icon_size = int(rect.height() * 0.8) - icon_x = rect.right() - icon_size - 10 - icon_y = rect.top() + (rect.height() - icon_size) // 2 - icon.paint(painter, icon_x, icon_y, icon_size, icon_size) - painter.setOpacity(1.0) - - # Setup text area - text_rect = rect.adjusted(self.PADDING, self.PADDING, -self.PADDING, -self.PADDING) - y = text_rect.top() - - # Draw title (bold, larger, 2 lines) - title = card_data.get('title', '') - title_font = option.font - title_font.setPointSize(int(option.font.pointSize() * 1)) - title_font.setWeight(QtGui.QFont.Weight.DemiBold) - painter.setFont(title_font) - painter.setPen(option.palette.text().color()) - - title_fm = QtGui.QFontMetrics(title_font) - title_height = 5 + title_fm.height() * self.TITLE_LINES - title_rect = QtCore.QRect(text_rect.left(), y, text_rect.width(), title_height) - - # Elide title text if it's too long for 2 lines - title_text = str(title) - max_width = title_rect.width() - - # Split into words and fit within 2 lines with eliding - words = title_text.split() - line1_words = [] - line2_words = [] - current_line = line1_words - - for word in words: - test_text = " ".join(current_line + [word]) - if title_fm.horizontalAdvance(test_text) <= max_width: - current_line.append(word) - elif current_line is line1_words and len(line2_words) == 0: - # Move to second line - current_line = line2_words - current_line.append(word) - else: - # Need to elide - break - - line1_text = " ".join(line1_words) - line2_text = " ".join(line2_words) - - # If there are remaining words, elide the second line - if len(line1_words) + len(line2_words) < len(words): - line2_text = title_fm.elidedText(title_text if not line1_text else " ".join(words[len(line1_words):]), - Qt.TextElideMode.ElideRight, max_width) - - # Draw title lines - painter.drawText(title_rect.left(), title_rect.top() + title_fm.ascent(), line1_text) - if line2_text: - painter.drawText(title_rect.left(), title_rect.top() + title_fm.ascent() + title_fm.height(), line2_text) - - y += title_height - - # Draw subtitle (smaller) - subtitle = card_data.get('subtitle', '') - if subtitle: - subtitle_font: QtGui.QFont = option.font - subtitle_font.setPointSize(int(font_size * 0.9)) - subtitle_font.setWeight(QtGui.QFont.Weight.Light) - painter.setFont(subtitle_font) - - subtitle_fm = QtGui.QFontMetrics(subtitle_font) - subtitle_height = subtitle_fm.height() - subtitle_rect = QtCore.QRect(text_rect.left(), y, text_rect.width(), subtitle_height) - - # Elide subtitle if too long - subtitle_text = subtitle_fm.elidedText(str(subtitle), Qt.TextElideMode.ElideRight, subtitle_rect.width()) - painter.drawText(subtitle_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, subtitle_text) - y += subtitle_height - - # Draw detail (bottom left) - detail = card_data.get('detail', '') - detail_width = 0 - if detail: - detail_font = option.font - detail_font.setPointSize(int(font_size * 0.8)) - painter.setFont(detail_font) - - detail_fm = QtGui.QFontMetrics(detail_font) - detail_height = detail_fm.height() - - # Reserve half width for detail, half for categories - max_detail_width = text_rect.width() // 2 - 10 - detail_rect = QtCore.QRect(text_rect.left(), text_rect.bottom() - detail_height, - max_detail_width, detail_height) - - # Elide detail if too long - detail_text_elided = detail_fm.elidedText(str(detail), Qt.TextElideMode.ElideRight, max_detail_width) - painter.drawText(detail_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, detail_text_elided) - detail_width = detail_fm.horizontalAdvance(detail_text_elided) + 10 - - # Draw categories (pipe separated, bottom right) - categories = card_data.get('categories', []) - if categories and isinstance(categories, (list, tuple)): - categories_text = " | ".join(str(cat) for cat in categories) - categories_font = option.font - categories_font.setPointSize(int(font_size * 0.8)) - painter.setFont(categories_font) - - categories_fm = QtGui.QFontMetrics(categories_font) - categories_height = categories_fm.height() - - # Adjust width to account for detail on left - available_width = text_rect.width() - detail_width - categories_rect = QtCore.QRect(text_rect.left() + detail_width, text_rect.bottom() - categories_height, - available_width, categories_height) - - # Elide categories if too long - categories_text_elided = categories_fm.elidedText(categories_text, Qt.TextElideMode.ElideRight, available_width) - painter.drawText(categories_rect, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, categories_text_elided) - - painter.restore() - - diff --git a/activity_browser/ui/delegates/new_formula.py b/activity_browser/ui/delegates/new_formula.py index b118624bb..db929ff8c 100644 --- a/activity_browser/ui/delegates/new_formula.py +++ b/activity_browser/ui/delegates/new_formula.py @@ -1,4 +1,3 @@ -from loguru import logger from qtpy import QtCore, QtWidgets from qtpy.QtGui import QFontMetrics, QFont from qtpy.QtCore import Qt @@ -31,14 +30,12 @@ def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): if hasattr(index.internalPointer(), 'scoped_parameters'): scope = index.internalPointer().scoped_parameters - elif hasattr(index.model(), 'scoped_parameters'): - scope = index.model().scoped_parameters(index) else: scope = {} from activity_browser.ui.widgets import ABFormulaEdit viewport = self.parent().findChild(QtWidgets.QWidget, "qt_scrollarea_viewport") - formula = ABFormulaEdit(viewport, scope, index.data(), simple=True) + formula = ABFormulaEdit(viewport, scope, index.data()) painter.setClipRect(option.rect) painter.translate(option.rect.topLeft()) @@ -52,8 +49,6 @@ def createEditor(self, parent, option, index): from activity_browser.ui.widgets import ABFormulaEdit if hasattr(index.internalPointer(), 'scoped_parameters'): scope = index.internalPointer().scoped_parameters - elif hasattr(index.model(), 'scoped_parameters'): - scope = index.model().scoped_parameters(index) else: scope = {} editor = ABFormulaEdit(parent, scope) diff --git a/activity_browser/ui/delegates/string.py b/activity_browser/ui/delegates/string.py index 2708f2336..4ded24422 100644 --- a/activity_browser/ui/delegates/string.py +++ b/activity_browser/ui/delegates/string.py @@ -7,7 +7,6 @@ class StringDelegate(QtWidgets.QStyledItemDelegate): def displayText(self, value, locale): if isinstance(value, (list, tuple)): - value = [str(v) for v in value] return ", ".join(value) return str(value) diff --git a/activity_browser/ui/delegates/uncertainty.py b/activity_browser/ui/delegates/uncertainty.py index cb782e719..52ff819c1 100644 --- a/activity_browser/ui/delegates/uncertainty.py +++ b/activity_browser/ui/delegates/uncertainty.py @@ -2,7 +2,9 @@ from qtpy import QtCore, QtWidgets from stats_arrays import uncertainty_choices as uc -from activity_browser.ui.dialogs import UncertaintyDialog +from activity_browser import actions + +from activity_browser.signals import signals class UncertaintyDelegate(QtWidgets.QStyledItemDelegate): @@ -10,50 +12,46 @@ class UncertaintyDelegate(QtWidgets.QStyledItemDelegate): `setModelData` stores the integer id of the selected uncertainty distribution. """ + + def __init__(self, parent=None): + super().__init__(parent) + uc.check_id_uniqueness() + self.choices = {u.description: u.id for u in uc.choices} + def displayText(self, value, locale): """Take the given integer id and return the description. Will return the 'Unknown' uncertainty description if the given id either cannot be found or the value is 'nan' (when id is not set) """ - if isinstance(value, (int, float)) and int(value) in uc.id_dict: - return uc.id_dict[int(value)].description - elif isinstance(value, dict) and value.get("uncertainty type") in uc.id_dict: - return uc[value["uncertainty type"]].description - return uc[0].description + try: + return uc[int(value)].description + except (IndexError, ValueError): + return uc[0].description def createEditor(self, parent, option, index): """Simply use the wizard for updating uncertainties. Send a signal.""" - from activity_browser import app - - item = index.internalPointer() - item_name = item.__class__.__name__ - - if item_name == "ParametersItem" or item_name == "ProjectParametersItem": - app.actions.ParameterUncertaintyModify.run(item["_parameter"].to_peewee_model()) - elif item_name == "ExchangesItem": - app.actions.ExchangeUncertaintyModify.run([item.exchange]) - elif item_name == "CharacterizationFactorsItem": - app.actions.CFUncertaintyModify.run( + if hasattr(self.parent(), "modify_uncertainty_action"): + self.parent().modify_uncertainty_action.trigger() + elif hasattr(index.internalPointer(), "exchange"): + item = index.internalPointer() + actions.ExchangeUncertaintyModify.run([item.exchange]) + elif index.internalPointer()["_impact_category_name"] is not None: + item = index.internalPointer() + actions.CFUncertaintyModify.run( item["_impact_category_name"], [(item["_id"], item["_cf"]),] ) - elif isinstance(index.data(), dict): - return UncertaintyDialog(parent=app.main_window, initial=index.data()) - - def setEditorData(self, editor, index: QtCore.QModelIndex): - pass - def updateEditorGeometry(self, editor, option, index): + def setEditorData(self, editor: QtWidgets.QComboBox, index: QtCore.QModelIndex): + """Simply use the wizard for updating uncertainties.""" pass def setModelData( self, - editor: UncertaintyDialog, + editor: QtWidgets.QComboBox, model: QtCore.QAbstractItemModel, index: QtCore.QModelIndex, ): """Read the current text and look up the actual ID of that uncertainty type.""" - if not editor.result() == QtWidgets.QDialog.Accepted: - return - - model.setData(index, editor.result_dict, QtCore.Qt.EditRole) + uc_id = self.choices.get(editor.currentText(), 0) + model.setData(index, uc_id, QtCore.Qt.EditRole) diff --git a/activity_browser/ui/dialogs/README.md b/activity_browser/ui/dialogs/README.md deleted file mode 100644 index ae8cfbd87..000000000 --- a/activity_browser/ui/dialogs/README.md +++ /dev/null @@ -1,269 +0,0 @@ -# dialogs - -UI dialog windows for various user interactions. - -## Overview - -This directory contains dialog windows used throughout Activity Browser for user interactions such as configuration, data entry, item selection, and information display. - -## Dialog Categories - -### Input Dialogs -Collect information from users: -- Text input dialogs -- Numeric value entry -- Form-based data entry -- Multi-field configuration - -### Selection Dialogs -Allow users to choose items: -- Activity selection -- Database selection -- Method selection -- File/directory choosers -- List item selection - -### Configuration Dialogs -Manage settings and preferences: -- Application settings -- Project settings -- Database properties -- Import/export configuration -- Plugin configuration - -### Information Dialogs -Display information to users: -- About dialog -- Progress dialogs -- Status messages -- Error and warning dialogs -- Help and documentation - -### Confirmation Dialogs -Request user confirmation: -- Delete confirmations -- Overwrite warnings -- Action confirmations -- Discard changes prompts - -## Common Dialog Types - -### QDialog-based -Standard modal dialogs: -```python -from qtpy.QtWidgets import QDialog - -class MyDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setup_ui() - - def accept(self): - if self.validate(): - # Process and close - super().accept() -``` - -### QMessageBox-based -Simple message dialogs: -```python -from qtpy.QtWidgets import QMessageBox - -result = QMessageBox.question( - parent, - "Confirm Delete", - "Are you sure you want to delete this item?", - QMessageBox.Yes | QMessageBox.No -) -``` - -### QFileDialog-based -File and directory selection: -```python -from qtpy.QtWidgets import QFileDialog - -filepath = QFileDialog.getOpenFileName( - parent, - "Select File", - "", - "Excel files (*.xlsx)" -) -``` - -## Dialog Features - -### Modal vs. Modeless -- **Modal** - Blocks parent window until closed (most common) -- **Modeless** - Allows interaction with parent (for utilities) - -### Button Boxes -Standard button configurations: -```python -from qtpy.QtWidgets import QDialogButtonBox - -buttons = QDialogButtonBox( - QDialogButtonBox.Ok | QDialogButtonBox.Cancel -) -buttons.accepted.connect(self.accept) -buttons.rejected.connect(self.reject) -``` - -### Validation -Validate before accepting: -```python -def accept(self): - if not self.name_input.text(): - QMessageBox.warning(self, "Error", "Name is required") - return - super().accept() -``` - -### Progress Indication -Show progress for long operations: -```python -from qtpy.QtWidgets import QProgressDialog - -progress = QProgressDialog("Processing...", "Cancel", 0, 100, parent) -progress.setWindowModality(Qt.WindowModal) -progress.setValue(50) -``` - -## Usage Patterns - -### Simple Confirmation -```python -from qtpy.QtWidgets import QMessageBox - -reply = QMessageBox.question( - self, - "Confirm", - "Delete this database?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No # Default button -) - -if reply == QMessageBox.Yes: - # Perform deletion - pass -``` - -### Custom Dialog -```python -class MyDialog(QDialog): - def __init__(self, data, parent=None): - super().__init__(parent) - self.data = data - self.setup_ui() - - def setup_ui(self): - layout = QVBoxLayout(self) - - # Add widgets - self.name_edit = QLineEdit() - layout.addWidget(QLabel("Name:")) - layout.addWidget(self.name_edit) - - # Add buttons - buttons = QDialogButtonBox( - QDialogButtonBox.Ok | QDialogButtonBox.Cancel - ) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) - - def get_result(self): - """Return dialog result.""" - return self.name_edit.text() -``` - -### Using Custom Dialog -```python -dialog = MyDialog(data, parent=self) -if dialog.exec_() == QDialog.Accepted: - result = dialog.get_result() - # Use result -``` - -## Development Guidelines - -When creating dialogs: - -1. **Inherit from QDialog** - Use Qt's base dialog class -2. **Set parent** - Pass parent widget for proper hierarchy -3. **Provide clear title** - Set window title with setWindowTitle() -4. **Use button boxes** - Standard OK/Cancel buttons -5. **Validate input** - Check data in accept() method -6. **Return results** - Provide method to get dialog results -7. **Handle cancellation** - Clean up if user cancels -8. **Size appropriately** - Fit content, but not too large -9. **Be modal when needed** - Block parent for critical choices -10. **Show progress** - Use QProgressDialog for long operations - -## Threading in Dialogs - -Long operations should use worker threads: - -```python -from activity_browser.ui.core.threading import ABThread - -class MyDialog(QDialog): - def accept(self): - # Show progress - self.progress = QProgressDialog("Processing...", None, 0, 0, self) - self.progress.show() - - # Run in background - worker = ABThread(self.process_data) - worker.finished.connect(self.on_complete) - worker.start() - - def on_complete(self): - self.progress.close() - super().accept() -``` - -## Signal Integration - -Dialogs should emit signals for application updates: - -```python -from activity_browser import app - -class MyDialog(QDialog): - def accept(self): - # Save data - self.save_changes() - - # Notify application - app.signals.data_changed.emit() - - super().accept() -``` - -## Accessibility - -Make dialogs accessible: -- Clear focus order (tab navigation) -- Keyboard shortcuts for buttons -- Screen reader compatible labels -- Escape key to cancel -- Enter key to accept (when safe) - -## Testing - -Test dialogs thoroughly: -```python -def test_my_dialog(qtbot): - dialog = MyDialog() - qtbot.addWidget(dialog) - - # Test initial state - assert dialog.name_edit.text() == "" - - # Simulate user input - qtbot.keyClicks(dialog.name_edit, "Test Name") - - # Test validation - dialog.accept() - assert dialog.result() == QDialog.Accepted -``` diff --git a/activity_browser/ui/dialogs/__init__.py b/activity_browser/ui/dialogs/__init__.py deleted file mode 100644 index bb65f2768..000000000 --- a/activity_browser/ui/dialogs/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .list_edit_dialog import ABListEditDialog -from .progress_dialog import ABProgressDialog -from .uncertainty_dialog import UncertaintyDialog - diff --git a/activity_browser/ui/dialogs/list_edit_dialog.py b/activity_browser/ui/dialogs/list_edit_dialog.py deleted file mode 100644 index 791a4ca98..000000000 --- a/activity_browser/ui/dialogs/list_edit_dialog.py +++ /dev/null @@ -1,363 +0,0 @@ -from qtpy import QtCore, QtGui, QtWidgets -from activity_browser.ui.icons import qicons - - -class DragHandleDelegate(QtWidgets.QStyledItemDelegate): - """Custom delegate that paints a drag handle icon on the left of each row. - - This delegate adds a visual affordance (grip icon) to indicate that rows - can be reordered via drag-and-drop. The icon is painted in the left margin - of each list item. - """ - - def paint(self, painter, option, index): - """Paint the item with a drag handle icon on the left side. - - Parameters - ---------- - painter : QtGui.QPainter - The painter to use for rendering. - option : QtWidgets.QStyleOptionViewItem - Style options for the item. - index : QtCore.QModelIndex - The model index of the item to paint. - """ - super().paint(painter, option, index) - - # Draw drag handle icon on the left - icon_size = 16 - icon_margin = 4 - icon_rect = QtCore.QRect( - option.rect.left() + icon_margin, - option.rect.top() + (option.rect.height() - icon_size) // 2, - icon_size, - icon_size - ) - - # Use a grip icon if available, otherwise use a simple visual indicator - if hasattr(qicons, 'drag_indicator'): - qicons.drag_indicator.paint(painter, icon_rect) - else: - # Fallback: draw three horizontal lines as a grip indicator - painter.save() - painter.setPen(QtGui.QPen(option.palette.mid().color(), 2)) - y_center = icon_rect.center().y() - x_left = icon_rect.left() + 2 - x_right = icon_rect.right() - 2 - for offset in [-4, 0, 4]: - y = y_center + offset - painter.drawLine(x_left, y, x_right, y) - painter.restore() - - -class ABListEditDialog(QtWidgets.QDialog): - """ - A dialog for editing a list or tuple of strings with drag-and-drop reordering. - - Parameters - ---------- - data : iterable of str - Initial values to populate the list. - title : str, optional - Window title for the dialog. Default is "Edit List/Tuple". - parent : QtWidgets.QWidget, optional - Parent widget for the dialog. - - Examples - -------- - >>> dialog = ABListEditDialog(["item1", "item2"], title="Edit Items") - >>> if dialog.exec_() == QtWidgets.QDialog.Accepted: - ... updated_items = dialog.get_data() - """ - - def __init__(self, data, title="Edit List/Tuple", parent=None): - super().__init__(parent) - self.setWindowTitle(title) - self.resize(420, 320) - - layout = QtWidgets.QVBoxLayout(self) - - # List widget - self.list = QtWidgets.QListWidget(self) - self.list.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) - self.list.setEditTriggers( - QtWidgets.QAbstractItemView.DoubleClicked | QtWidgets.QAbstractItemView.EditKeyPressed - ) - # Enable intuitive drag-and-drop reordering - self.list.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove) - self.list.setDefaultDropAction(QtCore.Qt.MoveAction) - self.list.setAlternatingRowColors(True) - self.list.setStyleSheet( - """ - QListWidget { alternate-background-color: palette(alternate-base); } - QListWidget::item { padding: 6px 28px 6px 28px; } - QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); } - """ - ) - # Set custom delegate to draw drag handles - self.list.setItemDelegate(DragHandleDelegate(self.list)) - - # OK/Cancel - self.button_box = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel - ) - - # Assemble layout - layout.addWidget(self.list) - layout.addWidget(self.button_box) - self.setLayout(layout) - - # Signals - self.button_box.accepted.connect(self.accept) - self.button_box.rejected.connect(self.reject) - self.list.itemChanged.connect(self.on_item_changed) - self.list.itemSelectionChanged.connect(self._on_selection_changed) - # Reposition inline buttons on scroll/resize/content changes - self.list.verticalScrollBar().valueChanged.connect(self._position_inline_buttons) - self.list.horizontalScrollBar().valueChanged.connect(self._position_inline_buttons) - self.list.viewport().installEventFilter(self) - - # Populate from provided data - self.load_data(data) - self._create_inline_buttons() - self._position_inline_buttons() - - # ---------- Data ---------- - def load_data(self, data): - """Load data into the list widget. - - Populates the list with the provided values. If no data is provided, - adds a single empty row to guide the user. - - Parameters - ---------- - data : iterable of str - Values to populate the list with. - """ - has_any = False - for value in data: - self._append_item(str(value)) - has_any = True - if not has_any: - # Provide a single empty row to guide the user - self._append_item("") - - def get_data(self, as_tuple=False): - """Retrieve the current list data, excluding empty rows. - - Parameters - ---------- - as_tuple : bool, optional - If True, return data as a tuple instead of a list. Default is False. - - Returns - ------- - list or tuple of str - Non-empty string values from the list, in current display order. - """ - values = [] - for row in range(self.list.count()): - item = self.list.item(row) - if item: - text = item.text().strip() - if text: - values.append(text) - return tuple(values) if as_tuple else values - - # ---------- Item helpers ---------- - def _append_item(self, text=""): - """Create and append a new editable, draggable item to the list. - - Parameters - ---------- - text : str, optional - Initial text content for the item. Default is empty string. - """ - item = QtWidgets.QListWidgetItem(text) - # editable + enabled + selectable + draggable - item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsDragEnabled) - self.list.addItem(item) - - def add_item(self): - """Add a new empty item and immediately start editing it. - - This method is connected to the inline add button. It creates a new row, - selects it, and opens it for editing, then repositions the floating buttons. - """ - self._append_item("") - # Select and start editing the newly added row - item = self.list.item(self.list.count() - 1) - self.list.setCurrentItem(item) - self.list.editItem(item) - self._position_inline_buttons() - - def remove_selected(self): - """Remove the currently selected item from the list. - - This method is connected to the inline remove button. After removal, - it ensures at least one empty row remains and adjusts the selection - to the next appropriate row. - """ - row = self._current_row() - if row is None: - return - self.list.takeItem(row) - # keep at least one empty row for guidance - if self.list.count() == 0: - self._append_item("") - # adjust selection - new_row = min(row, self.list.count() - 1) - if new_row >= 0: - self.list.setCurrentRow(new_row) - self._position_inline_buttons() - - # Drag-and-drop handles reordering; no explicit move buttons - - def on_item_changed(self, item: QtWidgets.QListWidgetItem): - """Handle item text changes by normalizing whitespace. - - Connected to the list widget's itemChanged signal. Collapses multiple - consecutive spaces into a single space to maintain clean data. - - Parameters - ---------- - item : QtWidgets.QListWidgetItem - The item that was changed. - """ - # No placeholder logic. Just normalize whitespace. - if item is None: - return - text = item.text() - if text is None: - return - # Collapse accidental multiple spaces - norm = " ".join(text.split()) - if norm != text: - # block signals to avoid recursion - self.list.blockSignals(True) - item.setText(norm) - self.list.blockSignals(False) - - # ---------- UI helpers ---------- - def _current_row(self): - """Get the index of the currently selected row. - - Returns - ------- - int or None - Row index (0-based) of the selected item, or None if nothing is selected. - """ - indexes = self.list.selectedIndexes() - if not indexes: - return None - return indexes[0].row() - - # ---------- Inline buttons ---------- - def _create_inline_buttons(self): - """Create floating icon buttons for add and remove operations. - - Creates two QToolButton instances: - - Add button: positioned at bottom-right corner of the list viewport - - Remove button: positioned inline with the currently selected row - - Both buttons use absolute positioning and are parented to the viewport - to float over the list content. - """ - # Add button at bottom-right - self.inline_add_btn = QtWidgets.QToolButton(self.list.viewport()) - self.inline_add_btn.setIcon(qicons.add) - self.inline_add_btn.setAutoRaise(True) - self.inline_add_btn.setToolTip("Add row") - self.inline_add_btn.clicked.connect(self.add_item) - - # Remove button aligned with selected row - self.inline_remove_btn = QtWidgets.QToolButton(self.list.viewport()) - self.inline_remove_btn.setIcon(qicons.delete) - self.inline_remove_btn.setAutoRaise(True) - self.inline_remove_btn.setToolTip("Remove selected row") - self.inline_remove_btn.clicked.connect(self.remove_selected) - self.inline_remove_btn.hide() - - def _on_selection_changed(self): - """Handle selection changes by repositioning inline buttons. - - Connected to the list widget's itemSelectionChanged signal. Ensures - the remove button follows the selected row. - """ - self._position_inline_buttons() - - def eventFilter(self, obj, event): - """Monitor viewport events to reposition floating buttons when needed. - - Watches for resize, update, and paint events on the list viewport, - deferring button repositioning until after layout updates complete. - - Parameters - ---------- - obj : QtCore.QObject - The object being monitored (should be self.list.viewport()). - event : QtCore.QEvent - The event that occurred. - - Returns - ------- - bool - Result from the parent event filter. - """ - if obj is self.list.viewport(): - if event.type() in (QtCore.QEvent.Resize, QtCore.QEvent.UpdateRequest, QtCore.QEvent.Paint): - # Defer reposition slightly to after layout updates - QtCore.QTimer.singleShot(0, self._position_inline_buttons) - return super().eventFilter(obj, event) - - def _position_inline_buttons(self): - """Calculate and apply absolute positions for floating buttons. - - Positions the add button at the bottom-right corner of the viewport, - and the remove button inline with the currently selected row (if any). - The remove button is only shown if it would be visible within the viewport. - - This method is called on: - - Selection changes - - Scroll events - - Viewport resize/paint/update events - - After adding or removing items - """ - if not hasattr(self, "inline_add_btn") or not hasattr(self, "inline_remove_btn"): - return - - # Position add button at bottom-right corner - viewport_rect = self.list.viewport().rect() - add_w = self.inline_add_btn.sizeHint().width() - add_h = self.inline_add_btn.sizeHint().height() - add_x = viewport_rect.right() - add_w - 6 - add_y = viewport_rect.bottom() - add_h - 6 - self.inline_add_btn.move(add_x, add_y) - self.inline_add_btn.show() - - # Position remove button aligned with selected row - row = self._current_row() - if row is None: - self.inline_remove_btn.hide() - return - item = self.list.item(row) - if item is None: - self.inline_remove_btn.hide() - return - rect = self.list.visualItemRect(item) - if not rect.isValid() or rect.height() <= 0: - self.inline_remove_btn.hide() - return - # Position inside the item's rect at right side with small margin - btn_w = self.inline_remove_btn.sizeHint().width() - btn_h = self.inline_remove_btn.sizeHint().height() - x = rect.right() - btn_w - 6 - y = rect.top() + (rect.height() - btn_h) // 2 - self.inline_remove_btn.move(x, y) - # Only show if fully or partially visible within viewport - if viewport_rect.intersects(QtCore.QRect(x, y, btn_w, btn_h)): - self.inline_remove_btn.show() - else: - self.inline_remove_btn.hide() - - diff --git a/activity_browser/ui/dialogs/progress_dialog.py b/activity_browser/ui/dialogs/progress_dialog.py deleted file mode 100644 index 5dec1df83..000000000 --- a/activity_browser/ui/dialogs/progress_dialog.py +++ /dev/null @@ -1,26 +0,0 @@ -from qtpy.QtWidgets import QProgressDialog - -from activity_browser.mod.tqdm import qt_tqdm -from activity_browser.mod.pyprind import qt_pyprind - - -class ABProgressDialog(QProgressDialog): - - @classmethod - def get_connected_dialog(cls, title: str) -> "ABProgressDialog": - from activity_browser.app import application - - dialog = cls(application.main_window) - dialog.setWindowTitle(title) - dialog.setLabelText("Initializing") - dialog.setAutoReset(False) - dialog.setCancelButton(None) - - qt_tqdm.updated.connect(dialog._receive_update) - qt_pyprind.updated.connect(dialog._receive_update) - - return dialog - - def _receive_update(self, title: str, value: int): - self.setLabelText(title) - self.setValue(value) diff --git a/activity_browser/ui/dialogs/uncertainty_dialog.py b/activity_browser/ui/dialogs/uncertainty_dialog.py deleted file mode 100644 index bf0eaa434..000000000 --- a/activity_browser/ui/dialogs/uncertainty_dialog.py +++ /dev/null @@ -1,483 +0,0 @@ -from __future__ import annotations - -from loguru import logger -from typing import Optional, Tuple - -import numpy as np -import seaborn as sns - -from qtpy import QtCore, QtGui, QtWidgets -import stats_arrays as sa - -from activity_browser.ui.widgets import ABPlot - - - - -EMPTY_UNCERTAINTY = { - "uncertainty type": sa.UndefinedUncertainty.id, - "loc": np.NaN, - "scale": np.NaN, - "shape": np.NaN, - "minimum": np.NaN, - "maximum": np.NaN, - "negative": False, -} - - -class UncertaintyDialog(QtWidgets.QDialog): - """Single-step dialog for defining a stats_arrays uncertainty. - - Mirrors the behavior of the UncertaintyWizard type page but returns a - stats_arrays structured array on accept. - - Usage: - ok, array = UncertaintyDialog.get_uncertainty(parent, initial=dict(...)) - if ok: - # array is a numpy structured array compatible with stats_arrays - """ - - def __init__(self, parent=None, initial: Optional[dict] = None): - super().__init__(parent) - self.setWindowTitle("Set Uncertainty") - self.setAttribute(QtCore.Qt.WA_DeleteOnClose) - - # State - self.dist = None - self.result_array = None # Filled on accept - self.result_dict = None # Filled on accept - self.previous_dist_id: Optional[int] = None - self.mean_is_calculated = { - sa.TriangularUncertainty.id, - sa.UniformUncertainty.id, - sa.DiscreteUniform.id, - sa.BetaUncertainty.id, - } - - # Top: distribution selection - box1 = QtWidgets.QGroupBox("Select the uncertainty distribution") - self.distribution = QtWidgets.QComboBox(box1) - self.distribution.addItems([ud.description for ud in sa.uncertainty_choices]) - self.distribution.currentIndexChanged.connect(self._on_distribution_changed) - - header_layout = QtWidgets.QGridLayout() - header_layout.addWidget(QtWidgets.QLabel("Distribution:"), 0, 0) - header_layout.addWidget(self.distribution, 0, 1) - box1.setLayout(header_layout) - - # Middle: parameters - self.fields_box = QtWidgets.QGroupBox("Fill out required parameters") - self.locale = QtCore.QLocale( - QtCore.QLocale.English, QtCore.QLocale.UnitedStates - ) - self.locale.setNumberOptions(QtCore.QLocale.RejectGroupSeparator) - self.validator = QtGui.QDoubleValidator() - self.validator.setLocale(self.locale) - - # loc/mean - self.loc_label = QtWidgets.QLabel("Loc:") - self.loc = QtWidgets.QLineEdit() - self.loc.setValidator(self.validator) - self.loc.textEdited.connect(self._sync_mean_from_loc) - self.loc.textEdited.connect(self._check_negative) - self.loc.textEdited.connect(self._generate_plot) - - self.mean_label = QtWidgets.QLabel("Mean:") - self.mean = QtWidgets.QLineEdit() - self.mean.setValidator(self.validator) - self.mean.textEdited.connect(self._sync_loc_from_mean) - self.mean.textEdited.connect(self._check_negative) - self.mean.textEdited.connect(self._generate_plot) - - # Calculated mean (read-only) for some dists - self.calc_mean_label = QtWidgets.QLabel("Mean:") - self.calc_mean = QtWidgets.QLineEdit("nan") - self.calc_mean.setDisabled(True) - - # Other parameters - self.scale_label = QtWidgets.QLabel("Sigma/scale:") - self.scale = QtWidgets.QLineEdit() - self.scale.setValidator(self.validator) - self.scale.textEdited.connect(self._generate_plot) - - self.shape_label = QtWidgets.QLabel("Shape:") - self.shape = QtWidgets.QLineEdit() - self.shape.setValidator(self.validator) - self.shape.textEdited.connect(self._generate_plot) - - self.min_label = QtWidgets.QLabel("Minimum:") - self.minimum = QtWidgets.QLineEdit() - self.minimum.setValidator(self.validator) - self.minimum.textEdited.connect(self._generate_plot) - - self.max_label = QtWidgets.QLabel("Maximum:") - self.maximum = QtWidgets.QLineEdit() - self.maximum.setValidator(self.validator) - self.maximum.textEdited.connect(self._generate_plot) - - # Hidden flag for negative mean on lognormal - self.negative = QtWidgets.QRadioButton(self) - self.negative.setChecked(False) - self.negative.setHidden(True) - - params_layout = QtWidgets.QGridLayout() - # row 0: read-only calculated mean (will be hidden for most dists) - params_layout.addWidget(self.calc_mean_label, 0, 0) - params_layout.addWidget(self.calc_mean, 0, 1) - # row 1: loc/mean pair - params_layout.addWidget(self.loc_label, 1, 0) - params_layout.addWidget(self.loc, 1, 1) - params_layout.addWidget(self.mean_label, 1, 3) - params_layout.addWidget(self.mean, 1, 4) - # row 2+: other params - params_layout.addWidget(self.scale_label, 2, 0) - params_layout.addWidget(self.scale, 2, 1) - params_layout.addWidget(self.shape_label, 3, 0) - params_layout.addWidget(self.shape, 3, 1) - params_layout.addWidget(self.min_label, 4, 0) - params_layout.addWidget(self.minimum, 4, 1) - params_layout.addWidget(self.max_label, 5, 0) - params_layout.addWidget(self.maximum, 5, 1) - self.fields_box.setLayout(params_layout) - - # Bottom: plot - self.plot = SimpleDistributionPlot(self) - - # Buttons - self.buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel - ) - self.buttons.accepted.connect(self._on_accept) - self.buttons.rejected.connect(self.reject) - - # Layout - layout = QtWidgets.QVBoxLayout() - layout.addWidget(box1) - layout.addWidget(self.fields_box) - layout.addWidget(self.plot) - layout.addWidget(self.buttons) - self.setLayout(layout) - - # Initialize values (defaults or provided initial) - self._apply_initial(initial or {}) - self._on_distribution_changed(self.distribution.currentIndex()) - self._sync_mean_from_loc() - self._generate_plot() - - # ---------- Public API ---------- - @staticmethod - def get_uncertainty_array( - parent=None, initial: Optional[dict] = None - ) -> Tuple[bool, Optional[np.ndarray]]: - dlg = UncertaintyDialog(parent, initial=initial) - ok = dlg.exec_() == QtWidgets.QDialog.Accepted - return ok, dlg.result_array if ok else None - - @staticmethod - def get_uncertainty_dict( - parent=None, initial: Optional[dict] = None - ) -> Tuple[bool, Optional[dict]]: - dlg = UncertaintyDialog(parent, initial=initial) - ok = dlg.exec_() == QtWidgets.QDialog.Accepted - return ok, dlg.result_dict if ok else None - - # ---------- Internal helpers ---------- - def _apply_initial(self, initial: dict) -> None: - # Use EMPTY_UNCERTAINTY defaults, overridden by initial - data = {k: v for k, v in EMPTY_UNCERTAINTY.items()} - data.update(initial or {}) - # Distribution - try: - uc_type = int(data.get("uncertainty type", 0)) - except Exception: - uc_type = 0 - self.distribution.setCurrentIndex(uc_type) - # Fields (string form for QLineEdit) - def to_str(val): - return "nan" if val is None or (isinstance(val, float) and np.isnan(val)) else str(val) - - self.loc.setText(to_str(data.get("loc", np.nan))) - self.scale.setText(to_str(data.get("scale", np.nan))) - self.shape.setText(to_str(data.get("shape", np.nan))) - self.minimum.setText(to_str(data.get("minimum", np.nan))) - self.maximum.setText(to_str(data.get("maximum", np.nan))) - self._check_negative() - - @property - def _distribution_loc_label(self) -> str: - if self.dist.id == sa.LognormalUncertainty.id: - return "Loc (ln(mean)):" - elif self.dist.id == sa.TriangularUncertainty.id: - return "Mode:" - elif self.dist.id == sa.BetaUncertainty.id: - return "Loc / alpha:" - elif self.dist.id in {sa.GammaUncertainty.id, sa.WeibullUncertainty.id}: - return "Loc / offset:" - else: - return "Mean:" - - def _hide_params(self, *params, hide: bool = True) -> None: - if "loc" in params: - self.loc_label.setHidden(hide) - self.loc.setHidden(hide) - if "scale" in params: - self.scale_label.setHidden(hide) - self.scale.setHidden(hide) - if "shape" in params: - self.shape_label.setHidden(hide) - self.shape.setHidden(hide) - if "min" in params: - self.min_label.setHidden(hide) - self.minimum.setHidden(hide) - if "max" in params: - self.max_label.setHidden(hide) - self.maximum.setHidden(hide) - - def _on_distribution_changed(self, index: int) -> None: - self.dist = sa.uncertainty_choices[index] - - # Show/hide fields per distribution (mirror wizard) - if self.dist.id in {0, 1}: # Undefined / NoUncertainty - self._hide_params("loc", "scale", "shape", "min", "max") - elif self.dist.id in {2, 3}: # Normal / Lognormal - self._hide_params("shape", "min", "max") - self._hide_params("loc", "scale", hide=False) - elif self.dist.id in {4, 7}: # Uniform / DiscreteUniform - self._hide_params("loc", "scale", "shape") - self._hide_params("min", "max", hide=False) - elif self.dist.id in {5, 6}: # Triangular / Bernoulli-like (min/max/loc) - self._hide_params("scale", "shape") - self._hide_params("loc", "min", "max", hide=False) - elif self.dist.id in {8, 9, 10, 11, 12}: # Other 3-param - self._hide_params("min", "max") - self._hide_params("loc", "scale", "shape", hide=False) - - # Special handling (lognormal and calculated mean label) - if self.dist.id == sa.LognormalUncertainty.id: - self.mean.setHidden(False) - self.mean_label.setHidden(False) - # Convert existing loc to log-space if coming from non-lognormal - if self.previous_dist_id is not None and self.previous_dist_id != sa.LognormalUncertainty.id: - self._extract_lognormal_loc_from_mean() - self._sync_mean_from_loc() - else: - self.mean.setHidden(True) - self.mean_label.setHidden(True) - # If switching away from lognormal, set loc to linear amount if mean present - if self.previous_dist_id == sa.LognormalUncertainty.id: - try: - mean_val = float(self.mean.text()) if self.mean.text() else np.nan - if not np.isnan(mean_val): - self.loc.setText(str(mean_val)) - except Exception: - pass - - # Calculated mean visibility - show_calc = self.dist.id in self.mean_is_calculated - self.calc_mean_label.setHidden(not show_calc) - self.calc_mean.setHidden(not show_calc) - - # Update labels - self.loc_label.setText(self._distribution_loc_label) - self.previous_dist_id = self.dist.id - self.fields_box.updateGeometry() - - # Update plot and OK state - self._generate_plot() - self._update_ok_state() - - def _extract_lognormal_loc_from_mean(self) -> None: - """Set loc to ln(mean) when switching to lognormal, if mean is known.""" - try: - mtxt = self.mean.text().strip() - if not mtxt: - return - val = float(mtxt) - if val == 0: - self.loc.setText("nan") - else: - val = -1 * val if val < 0 else val - self.loc.setText(str(np.log(val))) - except Exception: - self.loc.setText("nan") - - def _sync_mean_from_loc(self) -> None: - if not self.loc.text(): - return - try: - self.mean.setText(str(np.exp(float(self.loc.text())))) - except Exception: - self.mean.setText("nan") - self._update_ok_state() - - def _sync_loc_from_mean(self) -> None: - if not self.mean.hasAcceptableInput(): - self.loc.setText("nan") - self._update_ok_state() - return - try: - val = float(self.mean.text()) if self.mean.text() else float("nan") - except Exception: - val = float("nan") - if np.isnan(val) or val == 0: - self.loc.setText("nan") - else: - val = -1 * val if val < 0 else val - self.loc.setText(str(np.log(val))) - self._update_ok_state() - - def _check_negative(self) -> None: - # Special case for lognormal negative mean - try: - if not self.mean.hasAcceptableInput(): - return - val = float(self.mean.text()) if self.mean.text() else float("nan") - except Exception: - val = float("nan") - self.negative.setChecked(bool(not np.isnan(val) and val < 0)) - - def _standard_dist_fields(self, dist_id: int) -> list: - if dist_id in {2, 3}: - return ["loc", "scale"] - elif dist_id in {4, 7}: - return ["minimum", "maximum"] - elif dist_id in {5, 6}: - return ["loc", "minimum", "maximum"] - elif dist_id in {8, 9, 10, 11, 12}: - return ["loc", "scale", "shape"] - else: - return [] - - @property - def _uncertainty_info(self) -> dict: - data = {k: v for k, v in EMPTY_UNCERTAINTY.items()} - data["uncertainty type"] = self.distribution.currentIndex() - data["negative"] = bool(self.negative.isChecked()) - # Pull values from widgets - def as_float(txt: str) -> float: - try: - val = float(txt) - return val - except Exception: - return float("nan") - - for field in self._standard_dist_fields(data["uncertainty type"]): - widget = { - "loc": self.loc, - "scale": self.scale, - "shape": self.shape, - "minimum": self.minimum, - "maximum": self.maximum, - }[field] - data[field] = as_float(widget.text()) - return data - - def _completed_active_fields(self) -> bool: - # Mirror wizard validations - dist_id = self.dist.id - def ok_lineedit(le: QtWidgets.QLineEdit) -> bool: - return bool(le.hasAcceptableInput() and le.text()) - - if dist_id in {0, 1}: - return True - elif dist_id in {2, 3}: - return ok_lineedit(self.loc) and ok_lineedit(self.scale) - elif dist_id in {4, 7}: - return ok_lineedit(self.minimum) and ok_lineedit(self.maximum) - elif dist_id in {5, 6}: - if not (ok_lineedit(self.minimum) and ok_lineedit(self.maximum) and ok_lineedit(self.loc)): - return False - try: - return float(self.minimum.text()) < float(self.loc.text()) < float(self.maximum.text()) - except Exception: - return False - elif dist_id in {8, 9, 10, 11, 12}: - return ok_lineedit(self.scale) and ok_lineedit(self.shape) and ok_lineedit(self.loc) - return False - - def _update_ok_state(self) -> None: - ok_btn = self.buttons.button(QtWidgets.QDialogButtonBox.Ok) - ok_btn.setEnabled(self._completed_active_fields()) - - def _generate_plot(self) -> None: - # Update calculated mean if applicable and render sample - if self.dist is None: - return - complete = self._completed_active_fields() or self.dist.id in {sa.UndefinedUncertainty.id, sa.NoUncertainty.id} - if not complete: - self._update_ok_state() - return - array = self.dist.from_dicts(self._uncertainty_info) - # Calculated mean display for specific distributions - if self.dist.id in self.mean_is_calculated: - try: - calc = self.dist.statistics(array).get("mean") - except TypeError: - # DiscreteUniform workaround - array = self.dist.fix_nan_minimum(array) - calc = (array["maximum"] + array["minimum"]) / 2 - calc = calc.mean() if isinstance(calc, np.ndarray) else calc - self.calc_mean.setText(str(float(calc))) - # Vertical line value - if self.dist.id == sa.LognormalUncertainty.id: - vline = self.dist.statistics(array).get("median") - elif self.dist.id in {sa.UndefinedUncertainty.id, sa.NoUncertainty.id}: - # Best effort: use loc as "mean" placeholder - try: - vline = float(self.loc.text()) if self.loc.text() else np.nan - except Exception: - vline = np.nan - else: - vline = self.dist.statistics(array).get("mean") - # Sample data - data = self.dist.random_variables(array, 1000) - if not np.any(np.isnan(data)): - try: - self.plot.plot(data, vline) - except RuntimeError as e: - logger.error("%s: plotting failed, retry without KDE", e) - try: - sns.histplot(data.T, kde=False, stat="density", ax=self.plot.ax, edgecolor="none") - self.plot.ax.axvline(vline, label="Mean / amount", c="r", ymax=0.98) - self.plot.ax.legend(loc="upper right") - self.plot.canvas.draw() - except Exception: - pass - self._update_ok_state() - - def _on_accept(self) -> None: - try: - self.result_dict = self._uncertainty_info - self.result_array = self.dist.from_dicts(self._uncertainty_info) - except Exception as e: - QtWidgets.QMessageBox.warning( - self, - "Invalid uncertainty", - str(e), - QtWidgets.QMessageBox.Ok, - QtWidgets.QMessageBox.Ok, - ) - return - self.accept() - - -class SimpleDistributionPlot(ABPlot): - def plot(self, data: np.ndarray, mean: float, label: str = "Value"): - self.reset_plot() - try: - sns.histplot(data.T, kde=True, stat="density", ax=self.ax, edgecolor="none") - except RuntimeError as e: - logger.error("%s: Plotting without KDE.", e) - sns.histplot(data.T, kde=False, stat="density", ax=self.ax, edgecolor="none") - self.ax.set_xlabel(label) - self.ax.set_ylabel("Probability density") - # Add vertical line at given mean of x-axis - self.ax.axvline(mean, label="Mean / amount", c="r", ymax=0.98) - self.ax.legend(loc="upper right") - _, height = self.canvas.get_width_height() - self.setMinimumHeight(height / 2) - self.canvas.draw() - - -__all__ = ["UncertaintyDialog"] - diff --git a/activity_browser/ui/icons.py b/activity_browser/ui/icons.py index 37d304a91..011f21576 100644 --- a/activity_browser/ui/icons.py +++ b/activity_browser/ui/icons.py @@ -4,7 +4,6 @@ from qtpy.QtCore import Qt, QSize from qtpy.QtGui import QIcon, QPixmap - PACKAGE_DIR = Path(__file__).resolve().parents[1] @@ -19,86 +18,95 @@ def empty_icon(size: QSize = QSize(32, 32)) -> QIcon: return QIcon(pixmap) -icons = dict( +# CURRENTLY UNUSED ICONS + +# Modular LCA (keep until this is reintegrated) +# add_db = create_path('metaprocess', 'add_database.png') +# close_db = create_path('metaprocess', 'close_database.png') +# cut = create_path('metaprocess', 'cut.png') +# debug = create_path('main', 'ladybird.png') +# duplicate = create_path('metaprocess', 'duplicate.png') +# graph_lmp = create_path('metaprocess', 'graph_linkedmetaprocess.png') +# graph_mp = create_path('metaprocess', 'graph_metaprocess.png') +# load_db = create_path('metaprocess', 'open_database.png') +# metaprocess = create_path('metaprocess', 'metaprocess.png') +# new = create_path('metaprocess', 'new_metaprocess.png') +# save_db = create_path('metaprocess', 'save_database.png') +# save_mp = create_path('metaprocess', 'save_metaprocess.png') + +# key = create_path('main', 'key.png') +# search = create_path('main', 'search.png') +# switch = create_path('main', 'switch-state.png') + + +class Icons(object): # Icons from href="https://www.flaticon.com/ # MAIN - ab = create_path("main", "activitybrowser.png"), + ab = create_path("main", "activitybrowser.png") # arrows - right = create_path("main", "right.png"), - left = create_path("main", "left.png"), - forward = create_path("main", "forward.png"), - backward = create_path("main", "backward.png"), + right = create_path("main", "right.png") + left = create_path("main", "left.png") + forward = create_path("main", "forward.png") + backward = create_path("main", "backward.png") # Simple actions - delete = create_path("context", "delete.png"), - clear = create_path("context", "clear.png"), - copy = create_path("context", "copy.png"), - add = create_path("context", "add.png"), - edit = create_path("main", "edit.png"), - calculate = create_path("main", "calculate.png"), - question = create_path("context", "question.png"), - search = create_path("main", "search.png"), - filter = create_path("main", "filter.png"), - filter_outline = create_path("main", "filter_outline.png"), + delete = create_path("context", "delete.png") + clear = create_path("context", "clear.png") + copy = create_path("context", "copy.png") + add = create_path("context", "add.png") + edit = create_path("main", "edit.png") + calculate = create_path("main", "calculate.png") + question = create_path("context", "question.png") + search = create_path("main", "search.png") + filter = create_path("main", "filter.png") + filter_outline = create_path("main", "filter_outline.png") # database - import_db = create_path("main", "import_database.png"), - duplicate_database = create_path("main", "duplicate_database.png"), + import_db = create_path("main", "import_database.png") + duplicate_database = create_path("main", "duplicate_database.png") # activity - duplicate_activity = create_path("main", "duplicate_activity.png"), - duplicate_to_other_database = create_path("main", "import_database.png"), - parameterized = create_path("main", "parameterized.png"), + duplicate_activity = create_path("main", "duplicate_activity.png") + duplicate_to_other_database = create_path("main", "import_database.png") + parameterized = create_path("main", "parameterized.png") # windows - graph_explorer = create_path("main", "graph_explorer.png"), - issue = create_path("main", "idea.png"), - settings = create_path("main", "settings.png"), - history = create_path("main", "history.png"), - welcome = create_path("main", "welcome.png"), - main_window = create_path("main", "home.png"), + graph_explorer = create_path("main", "graph_explorer.png") + issue = create_path("main", "idea.png") + settings = create_path("main", "settings.png") + history = create_path("main", "history.png") + welcome = create_path("main", "welcome.png") + main_window = create_path("main", "home.png") # plugins - plugin = create_path("main", "plugin.png"), + plugin = create_path("main", "plugin.png") # nodes - process = create_path("nodes", "process.png"), - product = create_path("nodes", "product.png"), - waste = create_path("nodes", "waste.png"), - processproduct = create_path("nodes", "processproduct.png"), - biosphere = create_path("nodes", "biosphere.png"), - readonly_process = create_path("nodes", "read-only-process.png"), - - # exchanges - link = create_path("exchanges", "link.png"), - unlink = create_path("exchanges", "unlink.png"), - relink = create_path("exchanges", "relink.png"), + process = create_path("nodes", "process.png") + product = create_path("nodes", "product.png") + waste = create_path("nodes", "waste.png") + processproduct = create_path("nodes", "processproduct.png") + biosphere = create_path("nodes", "biosphere.png") + readonly_process = create_path("nodes", "read-only-process.png") # other - superstructure = create_path("main", "superstructure.png"), - copy_to_clipboard = create_path("main", "copy_to_clipboard.png"), - warning = create_path("context", "warning.png"), - critical = create_path("context", "critical.png"), - locked = create_path("main", "locked.png"), - unlocked = create_path("main", "unlocked.png"), - star = create_path("main", "star.png"), -) - - -class QIcons: - """Lazily loads QIcon instances only when accessed.""" - def __getattribute__(self, name): - if name == 'empty': - return empty_icon() - elif name in icons: - if name not in _initialized_icons: - _initialized_icons[name] = QIcon(icons[name]) - return _initialized_icons[name] - else: - raise AttributeError(f"QIcons has no icon '{name}'") - -_initialized_icons = {} -qicons = QIcons() + superstructure = create_path("main", "superstructure.png") + copy_to_clipboard = create_path("main", "copy_to_clipboard.png") + warning = create_path("context", "warning.png") + critical = create_path("context", "critical.png") + locked = create_path("main", "locked.png") + unlocked = create_path("main", "unlocked.png") + +class QIcons(Icons): + """Using the Icons class, returns the same attributes, but as QIcon type""" + empty = empty_icon() + + def __getattribute__(self, item): + return QIcon(Icons.__getattribute__(self, item)) + + +icons = Icons() +qicons = QIcons() diff --git a/activity_browser/ui/widgets/README.md b/activity_browser/ui/widgets/README.md deleted file mode 100644 index cb3bc002e..000000000 --- a/activity_browser/ui/widgets/README.md +++ /dev/null @@ -1,202 +0,0 @@ -# widgets - -Reusable custom widget components for the Activity Browser interface. - -## Overview - -This directory contains a collection of custom Qt widgets used throughout Activity Browser. These widgets extend Qt's base widgets with application-specific functionality, styling, and behavior. - -## Key Files - -### Abstract Base Classes -- **`abstract_page.py`** - Base class for main content area pages -- **`abstract_pane.py`** - Base class for dock-able side panels - -### Layout and Container Widgets -- **`central.py`** - Central widget that holds the main content area -- **`dock_widget.py`** - Custom dock widget with additional features -- **`tab_widget.py`** - Enhanced tab widget with custom styling - -### Input Widgets -- **`line_edit.py`** - Enhanced single-line text input -- **`text_edit.py`** - Multi-line text editor with additional features -- **`combobox.py`** - Drop-down selection with search and filtering -- **`formula_edit.py`** - Specialized editor for parameter formulas -- **`database_name_edit.py`** - Input widget for database names with validation - -### Display Widgets -- **`label.py`** - Custom labels with additional styling options -- **`tree_view.py`** - Enhanced tree view for hierarchical data -- **`plot.py`** - Plotting widgets for charts and graphs - -### Interactive Widgets -- **`buttons.py`** - Custom button variations (icon buttons, toggle buttons) -- **`button_collapser.py`** - Collapsible sections with expand/collapse buttons -- **`comparison_switch.py`** - Switch between different comparison views -- **`cutoff_menu.py`** - Menu for selecting cutoff thresholds -- **`menu.py`** - Enhanced context and popup menus - -### Utility Widgets -- **`file_selector.py`** - File/directory selection with browse button -- **`drop_overlay.py`** - Visual overlay for drag-and-drop operations -- **`line.py`** - Visual separator lines - -### Wizards -- **`wizard.py`** - Base wizard dialog for multi-step workflows -- **`wizard_page.py`** - Individual pages within wizards - -## Widget Categories - -### Page Widgets (AbstractPage) -Main content pages inherit from `AbstractPage`: -- Consistent toolbar integration -- Signal connection handling -- State management -- Layout conventions - -```python -from activity_browser.ui.widgets import AbstractPage - -class MyPage(AbstractPage): - def __init__(self, parent=None): - super().__init__(parent) - self.setup_ui() -``` - -### Pane Widgets (AbstractPane) -Dock-able panes inherit from `AbstractPane`: -- Dock widget functionality -- Visibility persistence -- Resize handling -- Title bar customization - -```python -from activity_browser.ui.widgets import AbstractPane - -class MyPane(AbstractPane): - def __init__(self, parent=None): - super().__init__(parent) - self.setup_content() -``` - -### Input Widgets -Enhanced input widgets with: -- Validation -- Placeholder text -- Clear buttons -- Auto-completion -- Format enforcement - -### Display Widgets -Specialized display widgets: -- Custom rendering -- Context menus -- Copy/export functionality -- Sorting and filtering - -## Common Patterns - -### Signal Connections -Widgets connect to global signals: -```python -from activity_browser import app - -app.signals.data_changed.connect(self.refresh) -``` - -### Validation -Input widgets validate data: -```python -class MyLineEdit(QLineEdit): - def validate_input(self): - if not self.text().strip(): - self.setStyleSheet("border: 1px solid red") - return False - return True -``` - -### Context Menus -Many widgets provide context menus: -```python -def contextMenuEvent(self, event): - menu = QMenu(self) - menu.addAction("Copy", self.copy_selection) - menu.addAction("Export", self.export_data) - menu.exec_(event.globalPos()) -``` - -## Styling - -Widgets use Qt stylesheets for consistent appearance: - -```python -self.setStyleSheet(""" - QWidget { - background-color: #ffffff; - color: #000000; - } - QPushButton { - border: 1px solid #cccccc; - border-radius: 3px; - padding: 5px; - } -""") -``` - -## Development Guidelines - -When creating custom widgets: - -1. **Inherit from appropriate base class** - Use AbstractPage/AbstractPane when applicable -2. **Emit signals for state changes** - Enable other components to react -3. **Support keyboard navigation** - Implement tab order and shortcuts -4. **Provide context menus** - Right-click actions for common operations -5. **Validate input** - Check data before accepting -6. **Handle errors gracefully** - Show user-friendly error messages -7. **Use consistent styling** - Follow application design patterns -8. **Document public API** - Docstrings for public methods and signals -9. **Make widgets reusable** - Avoid hard-coding application logic -10. **Test widgets independently** - Unit tests for widget behavior - -## Reusability - -Widgets should be: -- **Self-contained** - Minimal external dependencies -- **Configurable** - Properties for customization -- **Composable** - Can be combined into complex UIs -- **Generic** - Not tied to specific data models - -## Accessibility - -Consider accessibility: -- Keyboard navigation -- Screen reader compatibility -- High contrast support -- Focus indicators -- Logical tab order - -## Performance - -Optimize widget performance: -- Lazy loading of data -- Virtual scrolling for large lists -- Efficient repainting -- Debounced event handlers -- Cache computed values - -## Testing - -Widget tests should verify: -- Initial state and defaults -- User interactions (clicks, text entry) -- Signal emission -- Validation logic -- Edge cases and error handling - -Use pytest-qt for testing: -```python -def test_my_widget(qtbot): - widget = MyWidget() - qtbot.addWidget(widget) - # Test widget behavior -``` diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index 8f0434434..d92fe1773 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -1,14 +1,16 @@ -from .plot import ABPlot from .abstract_pane import ABAbstractPane from .comparison_switch import SwitchComboBox from .cutoff_menu import CutoffMenu from .line_edit import (ABLineEdit, SignalledComboEdit, SignalledLineEdit, SignalledPlainTextEdit) -from .text_edit import (ABAutoCompleTextEdit, ABTextEdit, MetaDataAutoCompleteTextEdit) +from .treeview import ABTreeView +from .item_model import ABItemModel +from .item import ABAbstractItem, ABBranchItem, ABDataItem from .line import ABHLine, ABVLine from .formula_edit import ABFormulaEdit +from .progress_dialog import ABProgressDialog -from .combobox import ABComboBox, CheckableComboBox +from .combobox import ABComboBox from .button_collapser import ABRadioButtonCollapser from .wizard import ABWizard from .wizard_page import ABWizardPage, ABThreadedWizardPage @@ -16,11 +18,9 @@ from .database_name_edit import DatabaseNameEdit from .dock_widget import ABDockWidget from .label import ABLabel +from .main_window import MainWindow from .central import CentralTabWidget from .menu import ABMenu +from .list_edit_dialog import ABListEditDialog from .drop_overlay import ABDropOverlay -from .tree_view import ABTreeView -from .buttons import ABCloseButton, ABMinimizeButton -from .tab_widget import ABTabWidget -from .web_engine_page import ABWebEnginePage -from .abstract_navigator import ABAbstractNavigator, ABAbstractGraph +from .database_selection_dialog import ABDatabaseSelectionDialog diff --git a/activity_browser/ui/widgets/abstract_navigator.py b/activity_browser/ui/widgets/abstract_navigator.py deleted file mode 100644 index 065e8b563..000000000 --- a/activity_browser/ui/widgets/abstract_navigator.py +++ /dev/null @@ -1,218 +0,0 @@ -import json -import os -from abc import abstractmethod -from copy import deepcopy -from typing import Type -from loguru import logger - -from qtpy import QtWebChannel, QtWebEngineWidgets, QtWidgets -from qtpy.QtCore import QObject, Qt, QUrl, Signal, Slot - -from activity_browser.ui.icons import qicons -from activity_browser.bwutils import filesystem - -from .web_engine_page import ABWebEnginePage - - -class ABAbstractNavigator(QtWidgets.QWidget): - HELP_TEXT = """ - This is the text shown when the user presses 'help'. - """ - HTML_FILE = "" - - def __init__(self, parent=None, css_file: str = "", *args, **kwargs): - super().__init__(parent) - - # Graph object subclassed from BaseGraph. - self.graph: Type[ABAbstractGraph] - - # Setup JS / Qt interactions - self.bridge = Bridge(self) - self.channel = QtWebChannel.QWebChannel(self) - self.channel.registerObject("bridge", self.bridge) - self.view = QtWebEngineWidgets.QWebEngineView(self) - self.page = ABWebEnginePage(self.view) - self.view.setPage(self.page) - self.view.loadFinished.connect(self.load_finished_handler) - self.view.setContextMenuPolicy(Qt.PreventContextMenu) - self.view.page().setWebChannel(self.channel) - self.url = QUrl.fromLocalFile(self.HTML_FILE) - self.css_file = css_file - - # Various Qt objects - self.label_help = QtWidgets.QLabel(self.HELP_TEXT) - self.button_toggle_help = QtWidgets.QPushButton("Help") - self.button_back = QtWidgets.QPushButton(qicons.backward, "") - self.button_forward = QtWidgets.QPushButton(qicons.forward, "") - self.button_refresh = QtWidgets.QPushButton("Refresh HTML") - self.button_random_activity = QtWidgets.QPushButton("Random Activity") - - def load_finished_handler(self, *args, **kwargs) -> None: - """Executed when webpage has been loaded for the first time or refreshed. - - Can be used to trigger a calculation after the webpage has been - completely loaded. - """ - pass - - @abstractmethod - def connect_signals(self) -> None: - self.button_toggle_help.clicked.connect(self.toggle_help) - self.button_back.clicked.connect(self.go_back) - self.button_forward.clicked.connect(self.go_forward) - self.button_refresh.clicked.connect(self.draw_graph) - self.button_random_activity.clicked.connect(self.random_graph) - - @abstractmethod - def construct_layout(self) -> None: - pass - - def toggle_help(self) -> None: - self.label_help.setVisible(self.label_help.isHidden()) - - def go_forward(self) -> None: - if self.graph.forward(): - self.send_json() - - def go_back(self) -> None: - if self.graph.back(): - self.send_json() - - def send_json(self) -> None: - if self.graph.json_data is None: - return - self.bridge.graph_ready.emit(self.graph.json_data) - css_path = get_static_css_path(self.css_file) - - with open(css_path, "r") as css_file: - css_code = css_file.read() - - style_element = "" - self.bridge.style.emit(style_element) - - def draw_graph(self) -> None: - self.view.load(self.url) - - @abstractmethod - def random_graph(self) -> None: - pass - - -ALL_FILTER = "All Files (*.*)" - - -def savefilepath(default_file_name: str, file_filter: str = ALL_FILTER): - from activity_browser.bwutils import filesystem - import bw2data as bd - - default = default_file_name or "Graph SVG Export" - safe_name = bd.utils.safe_filename(default, add_hash=False) - filepath, _ = QtWidgets.QFileDialog.getSaveFileName( - caption="Choose location to save svg", - dir=os.path.join(filesystem.get_project_path(), safe_name), - filter=file_filter, - ) - return filepath - - -def to_svg(svg): - """Export to .svg format.""" - # TODO: Exported filename - filepath = savefilepath(default_file_name="svg_export", file_filter="SVG (*.svg)") - if filepath: - if not filepath.endswith(".svg"): - filepath += ".svg" - svg_file = open(filepath, "w", encoding="utf-8") - svg_file.write(svg) - svg_file.close() - - -class Bridge(QObject): - graph_ready = Signal(str) - update_graph = Signal(object) - style = Signal(str) - - @Slot(str, name="node_clicked") - def node_clicked(self, click_text: str): - """Is called when a node is clicked in Javascript. - Args: - click_text: string of a serialized json dictionary describing - - the node that was clicked on - - mouse button and additional keys pressed - """ - click_dict = json.loads(click_text) - click_dict["key"] = ( - click_dict["database"], - click_dict["id"], - ) # since JSON does not know tuples - logger.info(f"Click information: {click_dict}") # TODO click_dict needs correcting - self.update_graph.emit(click_dict) - - @Slot(str, name="download_triggered") - def download_triggered(self, svg: str): - """Is called when a node is clicked in Javascript. - Args: - svg: string of svg - """ - to_svg(svg) - - -class ABAbstractGraph(object): - def __init__(self): - self.json_data = None - # stores previous graphs, if any, and enables back/forward buttons - self.stack = [] - # stores graphs that can be returned to after having used the "back" button - self.forward_stack = [] - - def update(self, delete_unstacked: bool = True) -> None: - self.store_previous() - if delete_unstacked: - self.forward_stack = [] - - def forward(self) -> bool: - """Go forward, if previously gone back.""" - if not self.forward_stack: - return False - self.retrieve_future() - self.update(delete_unstacked=False) - return True - - def back(self) -> bool: - """Go back to previous graph, if any.""" - if len(self.stack) <= 1: - return False - self.store_future() - self.update(delete_unstacked=False) - return True - - def store_previous(self) -> None: - """Store the current graph in the""" - self.stack.append((deepcopy(self.json_data))) - - def store_future(self) -> None: - """When going back, store current data in a queue.""" - self.forward_stack.append(self.stack.pop()) - self.json_data = self.stack.pop() - - def retrieve_future(self) -> None: - """Extract the last graph from the queue.""" - self.json_data = self.forward_stack.pop() - - @abstractmethod - def new_graph(self, *args, **kwargs) -> None: - pass - - def save_json_to_file(self, filename: str = "graph_data.json") -> None: - """Writes the current model´s JSON representation to the specifies file.""" - if self.json_data: - filepath = os.path.join(os.path.dirname(__file__), filename) - with open(filepath, "w") as outfile: - json.dump(self.json_data, outfile) - -def get_static_js_path(file_name: str = "") -> str: - return str(filesystem.get_package_path() / "static" / "javascript" / file_name) - - -def get_static_css_path(file_name: str = "") -> str: - return str(filesystem.get_package_path() / "static" / "css" / file_name) \ No newline at end of file diff --git a/activity_browser/ui/widgets/abstract_page.py b/activity_browser/ui/widgets/abstract_page.py deleted file mode 100644 index 6e017208a..000000000 --- a/activity_browser/ui/widgets/abstract_page.py +++ /dev/null @@ -1,8 +0,0 @@ -from qtpy import QtWidgets - - -class ABAbstractPage(QtWidgets.QWidget): - - def toggleViewAction(self, main_window): - """Return the toggle view action for this page.""" - return diff --git a/activity_browser/ui/widgets/buttons.py b/activity_browser/ui/widgets/buttons.py deleted file mode 100644 index a153cfd66..000000000 --- a/activity_browser/ui/widgets/buttons.py +++ /dev/null @@ -1,63 +0,0 @@ -from qtpy import QtWidgets, QtCore, QtGui -from qtpy.QtCore import Qt - - -class ABCloseButton(QtWidgets.QWidget): - """Custom close button with hover effect.""" - clicked: QtCore.SignalInstance = QtCore.Signal() - - def __init__(self, parent=None): - super().__init__(parent) - - - self.label = QtWidgets.QLabel("×", self) - - self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Weight.Light)) - self.label.setAlignment(Qt.AlignCenter) - self.label.setFixedSize(16, 16) - self.label.mousePressEvent = lambda event: self.clicked.emit() - - self.label.setStyleSheet(""" - QLabel { - border-radius: 8px; - background-color: transparent; - } - QLabel:hover { - background-color: rgba(255, 0, 0, 0.5); - } - """) - - layout = QtWidgets.QHBoxLayout() - layout.setContentsMargins(5, 0, 0, 0) - layout.addWidget(self.label) - self.setLayout(layout) - - -class ABMinimizeButton(QtWidgets.QWidget): - """Custom close button with hover effect.""" - clicked: QtCore.SignalInstance = QtCore.Signal() - - def __init__(self, parent=None): - super().__init__(parent) - - self.label = QtWidgets.QLabel("-", self) - - self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Weight.Light)) - self.label.setAlignment(Qt.AlignCenter) - self.label.setFixedSize(16, 16) - self.label.mousePressEvent = lambda event: self.clicked.emit() - - self.setStyleSheet(""" - QLabel { - border-radius: 8px; - background-color: transparent; - } - QLabel:hover { - background-color: rgba(42, 157, 244, 0.5); - } - """) - - layout = QtWidgets.QHBoxLayout() - layout.setContentsMargins(5, 0, 0, 0) - layout.addWidget(self.label) - self.setLayout(layout) diff --git a/activity_browser/ui/widgets/central.py b/activity_browser/ui/widgets/central.py index 4829b7239..726cc09c6 100644 --- a/activity_browser/ui/widgets/central.py +++ b/activity_browser/ui/widgets/central.py @@ -1,11 +1,14 @@ -from loguru import logger +from logging import getLogger from qtpy import QtWidgets -from .tab_widget import ABTabWidget +from activity_browser import signals -class CentralTabWidget(ABTabWidget): +log = getLogger(__name__) + + +class CentralTabWidget(QtWidgets.QTabWidget): """ A custom QTabWidget that manages groups of tabs and their associated pages. @@ -13,6 +16,17 @@ class CentralTabWidget(ABTabWidget): and ensuring that each page has a unique object name. """ + def __init__(self, *args): + """ + Initialize the CentralTabWidget. + + Args: + *args: Positional arguments passed to the parent QTabWidget. + """ + super().__init__(*args) + # Connect to the project changed signal to reset the current index to 0 + signals.project.changed.connect(self.reset) + @property def groups(self): """ @@ -45,7 +59,6 @@ def addToGroup(self, group: str, page: QtWidgets.QWidget): self.addTab(GroupTabWidget(group, self), group) group = self.groups[group] - self.setCurrentWidget(group) # Check if the page already exists in the group page_names = [group.widget(i).objectName() for i in range(group.count())] @@ -55,17 +68,22 @@ def addToGroup(self, group: str, page: QtWidgets.QWidget): page.setWindowTitle(name) # make sure the page has a title page.setParent(group) group.addTab(page, name) - group.setCurrentWidget(page) page.windowTitleChanged.connect(lambda title: group.setTabText(group.indexOf(page), title)) else: # Set the existing page as the current tab index = page_names.index(page.objectName()) group.setCurrentIndex(index) - page.deleteLater() # Clean up the newly created page since it already exists + + # Set the group and page as the current widgets + self.setCurrentWidget(group) + group.setCurrentWidget(page) + + def reset(self): + self.setCurrentIndex(0) -class GroupTabWidget(ABTabWidget): +class GroupTabWidget(QtWidgets.QTabWidget): """ A custom QTabWidget that represents a group of tabs. @@ -82,6 +100,9 @@ def __init__(self, name: str, *args): *args: Additional positional arguments passed to the parent QTabWidget. """ super().__init__(*args) + self.setMovable(True) # Allow tabs to be rearranged. + self.setTabsClosable(True) # Allow tabs to be closed. + self.setDocumentMode(True) # Enable document mode for a more modern appearance. self.setObjectName(name) # Set the object name for the widget. @@ -94,7 +115,6 @@ def connect_signals(self): - Connects the `tabCloseRequested` signal to the `tabClosed` method. - Connects the `project.changed` signal to the `deleteLater` method to clean up the widget. """ - from activity_browser.app import signals self.tabCloseRequested.connect(self.tabClosed) signals.project.changed.connect(self.deleteLater) diff --git a/activity_browser/ui/widgets/cutoff_menu.py b/activity_browser/ui/widgets/cutoff_menu.py index e0f1f5016..397d5ae2f 100644 --- a/activity_browser/ui/widgets/cutoff_menu.py +++ b/activity_browser/ui/widgets/cutoff_menu.py @@ -9,6 +9,7 @@ from collections import namedtuple from typing import Union +import numpy as np from qtpy import QtCore from qtpy.QtCore import QLocale, Qt, Signal, Slot from qtpy.QtGui import QDoubleValidator, QIntValidator @@ -412,7 +413,6 @@ def log_value(self) -> Union[int, float]: This function converts the 1-100 values and modifies these to 0.001-100 on a logarithmic scale. Rounding is done based on magnitude. """ - import numpy as np # Logarithmic math refresher: # BOP = Base, Outcome Power; @@ -437,8 +437,6 @@ def log_value(self) -> Union[int, float]: @log_value.setter def log_value(self, value: float) -> None: """Modify value from 0.001-100 to 1-100 logarithmically and set slider to value.""" - import numpy as np - value = int(float(value) * np.power(10, 3)) log_val = np.log10(value).round(3) set_val = log_val * 20 diff --git a/activity_browser/ui/widgets/database_name_edit.py b/activity_browser/ui/widgets/database_name_edit.py index 0d6aa05c2..5cc210a4b 100644 --- a/activity_browser/ui/widgets/database_name_edit.py +++ b/activity_browser/ui/widgets/database_name_edit.py @@ -1,5 +1,7 @@ from qtpy import QtWidgets, QtCore +import bw2data as bd + class DatabaseNameEdit(QtWidgets.QWidget): """ @@ -71,6 +73,5 @@ def setText(self, text: str): self.database_name.setText(text) def willOverwrite(self) -> bool: - import bw2data as bd return self.database_name.text() in bd.databases diff --git a/activity_browser/ui/widgets/dock_widget.py b/activity_browser/ui/widgets/dock_widget.py index df428393c..1917d89f1 100644 --- a/activity_browser/ui/widgets/dock_widget.py +++ b/activity_browser/ui/widgets/dock_widget.py @@ -1,8 +1,6 @@ -from qtpy import QtWidgets, QtGui +from qtpy import QtWidgets, QtCore, QtGui from qtpy.QtCore import Qt -from .buttons import ABCloseButton, ABMinimizeButton - class HideMode: Close = 1 @@ -34,10 +32,10 @@ def setWidget(self, widget): def button(self): if self._hide_mode == HideMode.Close: - button = ABCloseButton(self) + button = CloseButton(self) button.clicked.connect(self.close) else: - button = ABMinimizeButton(self) + button = MinimizeButton(self) button.clicked.connect(self.hide) return button @@ -66,3 +64,82 @@ def set_button(self, button): w.deleteLater() +class CloseButton(QtWidgets.QWidget): + """Custom close button with hover effect.""" + clicked: QtCore.SignalInstance = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + + + self.label = QtWidgets.QLabel("×", self) + + self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) + self.label.setAlignment(Qt.AlignCenter) + self.label.setFixedSize(16, 16) + self.label.mousePressEvent = lambda event: self.clicked.emit() + + self.label.setStyleSheet(""" + QLabel { + border-radius: 8px; + background-color: transparent; + } + QLabel:hover { + background-color: rgba(255, 0, 0, 0.5); + } + """) + + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(5, 0, 0, 0) + layout.addWidget(self.label) + self.setLayout(layout) + + +class MinimizeButton(QtWidgets.QWidget): + """Custom close button with hover effect.""" + clicked: QtCore.SignalInstance = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + + self.label = QtWidgets.QLabel("-", self) + + self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) + self.label.setAlignment(Qt.AlignCenter) + self.label.setFixedSize(16, 16) + self.label.mousePressEvent = lambda event: self.clicked.emit() + + self.setStyleSheet(""" + QLabel { + border-radius: 8px; + background-color: transparent; + } + QLabel:hover { + background-color: rgba(42, 157, 244, 0.5); + } + """) + + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(5, 0, 0, 0) + layout.addWidget(self.label) + self.setLayout(layout) + + +def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self.drag_start_pos = event.pos() + + +def mouseMoveEvent(self, event): + if not self.drag_start_pos: + return + + # Check if mouse moved beyond threshold + if (event.pos() - self.drag_start_pos).manhattanLength() > QtWidgets.QApplication.startDragDistance(): + index = self.tabAt(self.drag_start_pos) + if index >= 0: + startDrag(self, index) + +def startDrag(self, index): + """Start dragging a tab.""" + print("Dragging success") diff --git a/activity_browser/ui/widgets/drop_overlay.py b/activity_browser/ui/widgets/drop_overlay.py index 66ab1ce71..326b2e2d8 100644 --- a/activity_browser/ui/widgets/drop_overlay.py +++ b/activity_browser/ui/widgets/drop_overlay.py @@ -1,60 +1,24 @@ -from typing import Literal - from qtpy import QtWidgets, QtGui from qtpy.QtCore import Qt class ABDropOverlay(QtWidgets.QWidget): - opacityMap = { - "low": 100, - "medium": 150, - "high": 200, - } - - def __init__(self, parent=None, text="Drop here to create new exchanges"): + def __init__(self, parent=None): super().__init__(parent) - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) - self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground) - self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setAttribute(Qt.WA_TransparentForMouseEvents) + self.setAttribute(Qt.WA_NoSystemBackground) + self.setAttribute(Qt.WA_TranslucentBackground) self.setAutoFillBackground(False) self.resize(parent.size()) - self._text = text - self._opacity: Literal["low", "medium", "high"] = "medium" - - def hovering(self) -> bool: - cursor_pos = QtGui.QCursor.pos() - widget_rect = self.rect() - local_pos = self.mapFromGlobal(cursor_pos) - return widget_rect.contains(local_pos) - - def setOpacity(self, level: Literal["low", "medium", "high"]): - if level in self.opacityMap: - self._opacity = level - self.update() - - def opacity(self): - return self._opacity - - def text(self): - return self._text - - def setText(self, text: str): - self._text = text - self.update() - - def showEvent(self, event): - self.resize(self.parent().size()) - super().showEvent(event) - def paintEvent(self, event): painter = QtGui.QPainter(self) - painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) - painter.fillRect(self.rect(), QtGui.QColor(0, 100, 255, self.opacityMap[self.opacity()])) # Semi-transparent blue - painter.setPen(Qt.GlobalColor.white) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.fillRect(self.rect(), QtGui.QColor(0, 100, 255, 200)) # Semi-transparent blue + painter.setPen(Qt.white) font = self.font() font.setBold(True) painter.setFont(font) - painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self.text()) + painter.drawText(self.rect(), Qt.AlignCenter, "Drop here to create new exchanges") diff --git a/activity_browser/ui/widgets/formula_edit.py b/activity_browser/ui/widgets/formula_edit.py index 47920f135..0a1c6e564 100644 --- a/activity_browser/ui/widgets/formula_edit.py +++ b/activity_browser/ui/widgets/formula_edit.py @@ -5,13 +5,13 @@ from asteval import make_symbol_table, Interpreter -from qtpy.QtWidgets import QApplication, QWidget, QCompleter, QTableView +from qtpy.QtWidgets import QApplication, QWidget, QCompleter, QTableView, QSizePolicy from qtpy.QtGui import QPainter, QColor, QFontMetrics, QFontDatabase, QPainterPath, QPen, QFont from qtpy.QtCore import QTimer, Qt, QAbstractTableModel, QModelIndex from activity_browser.static import fonts - +QFontDatabase.addApplicationFont(fonts.__path__[0] + "/mono.ttf") operators = r"+\-*/%=<>!&|^~" pattern = r"\b[a-zA-Z_]\w*\b|[\d.]+|[\"'{}:,+\-*/^()\[\]]| +" @@ -56,9 +56,7 @@ class Colors: class ABFormulaEdit(QWidget): - def __init__(self, parent=None, scope=None, text=None, simple=False): - QFontDatabase.addApplicationFont(fonts.__path__[0] + "/mono.ttf") - + def __init__(self, parent=None, scope=None, text=None): super().__init__(parent) self.scope = scope or {} self.error = False @@ -69,16 +67,12 @@ def __init__(self, parent=None, scope=None, text=None, simple=False): self.scroll_offset = 0 # Scroll position for long text self.padding = 5 # Left padding for text inside the box self.dragging = False # Track if mouse is dragging - self.text = text or "" # Stores user input font = self.font() font.setFamily("JetBrains Mono") font.setPointSize(9) self.setFont(font) - if simple: - return - self.timer = QTimer(self) self.timer.timeout.connect(self.toggle_cursor) self.timer.start(500) # Blink cursor every 500ms @@ -93,6 +87,8 @@ def __init__(self, parent=None, scope=None, text=None, simple=False): self.completer.setCompletionColumn(0) self.completer.activated.connect(self.insert_completion) + self.text = text or "" # Stores user input + @property def text(self): return self._text @@ -294,45 +290,28 @@ def get_cursor_position_from_x(self, x): x_offset = x - self.padding + self.scroll_offset cursor_pos = len(self.text) - for i in range(len(self.text) + 1): - char_x = font_metrics.horizontalAdvance(self.text[:i]) - if i < len(self.text): - next_char_x = font_metrics.horizontalAdvance(self.text[:i + 1]) - mid_point = (char_x + next_char_x) / 2 - if x_offset < mid_point: - cursor_pos = i - break - else: - # Past the end of the text - if x_offset >= char_x: - cursor_pos = i - break + for i in range(len(self.text)): + if font_metrics.horizontalAdvance(self.text[:i]) > x_offset: + cursor_pos = i + break return cursor_pos def mousePressEvent(self, event): """Handles mouse click events to set cursor position and start selection.""" - if self.rect().contains(event.pos()): + if 10 <= event.x() <= 390 and 10 <= event.y() <= 40: self.cursor_pos = self.get_cursor_position_from_x(event.x()) - self.selection_start = None # Clear selection initially + self.selection_start = self.cursor_pos # Start selection self.selection_end = None # Reset end position self.dragging = True # Start dragging self.adjust_scroll() - self.cursor_visible = True # Show cursor immediately - self.update() # Force immediate redraw - self.timer.stop() # Stop the timer - self.timer.start(500) # Restart blink timer + self.update() def mouseMoveEvent(self, event): """Handles mouse dragging for text selection.""" if self.dragging: - new_pos = self.get_cursor_position_from_x(event.x()) - # Start selection on first move if not already started - if self.selection_start is None and new_pos != self.cursor_pos: - self.selection_start = self.cursor_pos - if self.selection_start is not None: - self.selection_end = new_pos - self.cursor_pos = new_pos + self.selection_end = self.get_cursor_position_from_x(event.x()) + self.cursor_pos = self.selection_end self.adjust_scroll() self.update() @@ -354,7 +333,6 @@ def paintEvent(self, event): painter.setPen(Qt.NoPen) painter.fillRect(self.rect(), background_color) self.paint_text(painter) - painter.end() def paint_text(self, painter: QPainter): painter.setFont(self.font()) @@ -382,7 +360,7 @@ def paint_text(self, painter: QPainter): if not painter.pen() == Qt.NoPen: pass - elif token_type == "NUMBER": + if token_type == "NUMBER": painter.setPen(Colors.number) elif token_type in ["SQSTRING", "DQSTRING"]: painter.setPen(Colors.string) diff --git a/activity_browser/ui/widgets/line_edit.py b/activity_browser/ui/widgets/line_edit.py index 0a5c8ea3a..655d269d5 100644 --- a/activity_browser/ui/widgets/line_edit.py +++ b/activity_browser/ui/widgets/line_edit.py @@ -1,6 +1,7 @@ from qtpy import QtWidgets from qtpy.QtCore import QTimer, Slot, Signal, SignalInstance from qtpy.QtGui import QTextFormat +from qtpy.QtWidgets import QCompleter class ABLineEdit(QtWidgets.QLineEdit): @@ -48,12 +49,12 @@ def _text_changed(self, text: str) -> None: @Slot(name="customEditFinish") def _editing_finished(self) -> None: - from activity_browser import app + from activity_browser import actions after = self.text() if self._before != after: self._before = after - app.actions.ActivityModify.run(self._key, self._field, after) + actions.ActivityModify.run(self._key, self._field, after) class SignalledPlainTextEdit(QtWidgets.QPlainTextEdit): @@ -77,11 +78,11 @@ def highlight(self): self.setExtraSelections([selection]) def focusOutEvent(self, event): - from activity_browser import app + from activity_browser import actions after = self.toPlainText() if self._before != after: - app.actions.ActivityModify.run(self._key, self._field, after) + actions.ActivityModify.run(self._key, self._field, after) super().focusOutEvent(event) def refresh_text(self, text: str) -> None: @@ -103,10 +104,19 @@ def __init__(self, key, field, contents="", parent=None): self._field = field def focusOutEvent(self, event): - from activity_browser import app + from activity_browser import actions after = self.currentText() if self._before != after: self._before = after - app.actions.ActivityModify.run(self._key, self._field, after) + actions.ActivityModify.run(self._key, self._field, after) super(SignalledComboEdit, self).focusOutEvent(event) + + +class AutoCompleteLineEdit(QtWidgets.QLineEdit): + """Line Edit with a completer attached""" + + def __init__(self, items: list[str], parent=None): + super().__init__(parent=parent) + completer = QCompleter(items, self) + self.setCompleter(completer) diff --git a/activity_browser/ui/widgets/menu.py b/activity_browser/ui/widgets/menu.py index c0399b076..52f332749 100644 --- a/activity_browser/ui/widgets/menu.py +++ b/activity_browser/ui/widgets/menu.py @@ -1,10 +1,10 @@ from qtpy import QtWidgets -from typing import Callable +from typing import Callable, Optional from inspect import signature class ABMenu(QtWidgets.QMenu): - menuSetup: list[Callable[["ABMenu", QtWidgets.QWidget], None]] + menuSetup: list[Callable[["ABMenu", Optional[QtWidgets.QWidget]], None]] title: str = None def __init__(self, pos=None, parent=None, title: str = None): @@ -19,11 +19,3 @@ def __init__(self, pos=None, parent=None, title: str = None): def add(self, action, *args, enable=True, text=None, **kwargs): qaction = action.get_QAction(*args, parent=self, enabled=enable, text=text, **kwargs) self.addAction(qaction) - - def callback(self, text: str, func: Callable, args: list = None, kwargs: dict = None): - args = args or [] - kwargs = kwargs or {} - - action = QtWidgets.QAction(text, self) - action.triggered.connect(lambda: func(*args, **kwargs)) - self.addAction(action) diff --git a/activity_browser/ui/widgets/plot.py b/activity_browser/ui/widgets/plot.py deleted file mode 100644 index b9bdf1f27..000000000 --- a/activity_browser/ui/widgets/plot.py +++ /dev/null @@ -1,64 +0,0 @@ -from qtpy import QtWidgets - -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg -from matplotlib.figure import Figure - - -class ABPlot(QtWidgets.QWidget): - ALL_FILTER = "All Files (*.*)" - PNG_FILTER = "PNG (*.png)" - SVG_FILTER = "SVG (*.svg)" - - def __init__(self, parent=None): - super().__init__(parent) - # create figure, canvas, and axis - self.figure = Figure(constrained_layout=True) - self.canvas = FigureCanvasQTAgg(self.figure) - self.canvas.setMinimumHeight(0) - - self.ax = self.figure.add_subplot(111) # create an axis - self.plot_name = "Figure" - - # set the layout - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.canvas) - self.setLayout(layout) - self.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding - ) - self.updateGeometry() - - def plot(self, *args, **kwargs): - raise NotImplementedError - - def reset_plot(self) -> None: - self.figure.clf() - self.ax = self.figure.add_subplot(111) - - def get_canvas_size_in_inches(self): - return tuple(x / self.figure.dpi for x in self.canvas.get_width_height()) - - def to_png(self): - """Export to .png format.""" - from activity_browser.bwutils.commontasks import savefilepath - - filepath = savefilepath( - default_file_name=self.plot_name, file_filter=self.PNG_FILTER - ) - if filepath: - if not filepath.endswith(".png"): - filepath += ".png" - self.figure.savefig(filepath) - - def to_svg(self): - """Export to .svg format.""" - from activity_browser.bwutils.commontasks import savefilepath - - filepath = savefilepath( - default_file_name=self.plot_name, file_filter=self.SVG_FILTER - ) - if filepath: - if not filepath.endswith(".svg"): - filepath += ".svg" - self.figure.savefig(filepath) - diff --git a/activity_browser/ui/widgets/tab_widget.py b/activity_browser/ui/widgets/tab_widget.py deleted file mode 100644 index d3a00561b..000000000 --- a/activity_browser/ui/widgets/tab_widget.py +++ /dev/null @@ -1,61 +0,0 @@ -from qtpy import QtWidgets - -from .buttons import ABCloseButton, ABMinimizeButton - - -class ABTabWidget(QtWidgets.QTabWidget): - def __init__(self, *args, **kwargs): - """ - Initialize the GroupTabWidget. - - Args: - name (str): The name of the group, used as the object name for the widget. - *args: Additional positional arguments passed to the parent QTabWidget. - """ - super().__init__(*args, **kwargs) - self.setMovable(True) # Allow tabs to be rearranged. - self.setTabsClosable(True) # Allow tabs to be closed. - self.tabBar().setExpanding(False) - - - def resizeEvent(self, event): - super().resizeEvent(event) - # Force the tab bar to always fill the full width - self.tabBar().setMinimumWidth(self.width()) - - def addTab(self, widget, label, show_minimize=False): - """Override addTab to add custom buttons to each tab. - - Args: - widget: The widget to add as a tab - label: The label for the tab - show_minimize: If True, show minimize button; if False, show close button - """ - index = super().addTab(widget, label) - self._set_buttons(index, widget, show_minimize) - return index - - def insertTab(self, index, widget, label, show_minimize=False): - """Override insertTab to add custom buttons to each tab. - - Args: - index: The index at which to insert the tab - widget: The widget to add as a tab - label: The label for the tab - show_minimize: If True, show minimize button; if False, show close button - """ - index = super().insertTab(index, widget, label) - self._set_buttons(index, widget, show_minimize) - return index - - def _set_buttons(self, index, widget, show_minimize=False): - tab_bar = self.tabBar() - button = ABMinimizeButton() if show_minimize else ABCloseButton() - tab_bar.setTabButton(index, QtWidgets.QTabBar.ButtonPosition.RightSide, button) - button.clicked.connect(lambda w=widget: self.closeTabByWidget(w)) - - def closeTabByWidget(self, widget): - """Handle close button click using the widget reference.""" - index = self.indexOf(widget) - if index >= 0: - self.tabCloseRequested.emit(index) diff --git a/activity_browser/ui/widgets/text_edit.py b/activity_browser/ui/widgets/text_edit.py deleted file mode 100644 index 7c1a20a4f..000000000 --- a/activity_browser/ui/widgets/text_edit.py +++ /dev/null @@ -1,255 +0,0 @@ -from qtpy import QtWidgets -from qtpy.QtCore import QTimer, Signal, SignalInstance, QStringListModel, Qt -from qtpy.QtGui import QSyntaxHighlighter, QTextCharFormat, QTextDocument, QFont -from qtpy.QtWidgets import QCompleter, QStyledItemDelegate, QStyle - - -class UnknownWordHighlighter(QSyntaxHighlighter): - def __init__(self, parent: QTextDocument, known_words: set): - super().__init__(parent) - self.known_words = known_words - - # define the format for unknown words - self.unknown_format = QTextCharFormat() - self.unknown_format.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) - self.unknown_format.setUnderlineColor(Qt.red) - - def highlightBlock(self, text: str): - if text.startswith("="): - return - words = text.split() - index = 0 - for word in words: - word_len = len(word) - if word and word not in self.known_words: - self.setFormat(index, word_len, self.unknown_format) - index += word_len + 1 # +1 for the space - - -class AutoCompleteDelegate(QStyledItemDelegate): - def __init__(self, parent=None): - super().__init__(parent) - self.current_word_index = -1 - - def paint(self, painter, option, index): - text = index.data(Qt.DisplayRole) - - painter.save() - - # Draw selection background if selected - if option.state & QStyle.State_Selected: - painter.fillRect(option.rect, option.palette.highlight()) - painter.setPen(option.palette.highlightedText().color()) - else: - painter.setPen(option.palette.text().color()) - - # Split text into words and draw each with appropriate font - words = text.split(" ") - x = option.rect.x() - y = option.rect.y() - spacing = 4 # space between words - font = option.font - metrics = painter.fontMetrics() - - for i, word in enumerate(words): - word_font = QFont(font) - if i+1 == self.current_word_index: - word_font.setBold(True) - painter.setFont(word_font) - - word_width = metrics.horizontalAdvance(word) - painter.drawText(x, y + metrics.ascent() + (option.rect.height() - metrics.height()) // 2, word) - x += word_width + spacing - painter.restore() - - -class ABTextEdit(QtWidgets.QTextEdit): - textChangedDebounce: SignalInstance = Signal(str) - _debounce_ms = 250 - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self._debounce_timer = QTimer(self, singleShot=True) - - self.textChanged.connect(self._set_debounce) - self._debounce_timer.timeout.connect(self._emit_debounce) - - def _set_debounce(self): - self._debounce_timer.setInterval(self._debounce_ms) - self._debounce_timer.start() - - def _emit_debounce(self): - self.textChangedDebounce.emit(self.toPlainText()) - - def debounce(self): - return self._debounce_ms - - def setDebounce(self, ms: int): - self._debounce_ms = ms - - -class ABAutoCompleTextEdit(ABTextEdit): - def __init__(self, parent=None, highlight_unknown=False): - from activity_browser.bwutils.metadata import MetaDataStore # avoid circular import, should we refactor? - - self.mds = MetaDataStore() - super().__init__(parent=parent) - - self.auto_complete_word = "" - - # autocompleter settings - self.model = QStringListModel() - self.completer = QCompleter(self.model) - self.completer.setWidget(self) - self.popup = self.completer.popup() - self.delegate = AutoCompleteDelegate(self.popup) # set custom delegate to bold the current word - self.popup.setItemDelegate(self.delegate) - self.popup.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.completer.setPopup(self.popup) - self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) # allow all items in popup list - self.completer.activated.connect(self._insert_auto_complete) - - self.textChanged.connect(self._sanitize_input) - if highlight_unknown: - self.highlighter = UnknownWordHighlighter(self.document(), set()) - self.cursorPositionChanged.connect(self._set_autocomplete_items) - - def keyPressEvent(self, event): - key = event.key() - - if key in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Tab): - # insert an autocomplete item - # capture enter/return/tab key - index = self.popup.currentIndex() - completion_text = index.data(Qt.DisplayRole) - self.completer.activated.emit(completion_text) - return - elif key in (Qt.Key_Space,): - self.popup.close() - - super().keyPressEvent(event) - - # trigger on text input keys - if event.text() or key in (Qt.LeftArrow, Qt.RightArrow): # filters out non-text keys except l/r arrows - self._set_autocomplete_items() - - def _sanitize_input(self): - raise NotImplementedError - - def _set_autocomplete_items(self): - raise NotImplementedError - - def _insert_auto_complete(self, completion): - cursor = self.textCursor() - position = cursor.position() - completion = completion + " " # add space to end of new text - - # find where to put cursor back - new_position = position - while new_position < len(completion) and completion[new_position] != " ": - new_position += 1 - new_position += 1 # add one char for space - - # set new text from completion - self.blockSignals(True) - self.clear() - self.setText(completion) - # set the cursor location - cursor.setPosition(min(new_position, len(completion))) - self.setTextCursor(cursor) - self.blockSignals(False) - - # house keeping - self._emit_debounce() - self.popup.close() - self.auto_complete_word = "" - self.model.setStringList([]) - - -class MetaDataAutoCompleteTextEdit(ABAutoCompleTextEdit): - """TextEdit with MetaDataStore completer attached.""" - def __init__(self, parent=None): - super().__init__(parent=parent, highlight_unknown=True) - self.database_name = "" - - def _sanitize_input(self): - if not self.mds.searcher: - return - - self._debounce_timer.stop() - text = self.toPlainText() - clean_text = self.mds.searcher.ONE_SPACE_PATTERN.sub(" ", text) - - if clean_text != text: - cursor = self.textCursor() - position = cursor.position() - self.blockSignals(True) - self.clear() - self.insertPlainText(clean_text) - self.blockSignals(False) - cursor.setPosition(min(position, len(clean_text))) - self.setTextCursor(cursor) - - known_words = set() - for identifier in self.mds.searcher.database_id_manager(self.database_name): - known_words.update(self.mds.searcher.identifier_to_word[identifier].keys()) - self.highlighter.known_words = known_words - - if len(text) == 0: - self.popup.close() - self._set_debounce() - - def _set_autocomplete_items(self): - if not self.mds.searcher: - return - - text = self.toPlainText() - if text.startswith("="): - self.model.setStringList([]) - self.auto_complete_word = "" - self.popup.close() - return - - # find the start and end of the word under the cursor - cursor = self.textCursor() - position = cursor.position() - start = position - while start > 0 and text[start - 1] != " ": - start -= 1 - end = position - while end < len(text) and text[end] != " ": - end += 1 - current_word = text[start:end] - if not current_word: - self.model.setStringList([]) - self.popup.close() - self.auto_complete_word = "" - return - if self.auto_complete_word == current_word: - # avoid unnecessary auto_complete calls if the current word didnt change - return - self.auto_complete_word = current_word - - context = set((text[:start] + text[end:]).split(" ")) - self.delegate.current_word_index = len(text[:start].split(" ")) # current word index for bolding - # get suggestions for the current word - suggestions = self.mds.searcher.auto_complete(current_word, context=context, database=self.database_name) - suggestions = suggestions[:6] # at most 6, though we should get ~3 usually - # replace the current word with each alternative - items = [] - for alt in suggestions: - new_text = text[:start] + alt + text[end:] - items.append(new_text) - if len(items) == 0: - self.popup.close() - return - - self.model.setStringList(items) - # set correct height now that we have data - max_height = max( - 20, - self.popup.sizeHintForRow(0) * 3 + 2 * self.popup.frameWidth() - ) - self.popup.setMaximumHeight(max_height) - self.completer.complete() diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py deleted file mode 100644 index a94ee9777..000000000 --- a/activity_browser/ui/widgets/tree_view.py +++ /dev/null @@ -1,259 +0,0 @@ -from loguru import logger - -from qtpy import QtWidgets, QtCore, QtGui - -from activity_browser.ui import core - -from .line_edit import ABLineEdit - - -class ABTreeView(QtWidgets.QTreeView): - # fired when the filter is applied, fires False when an exception happens during querying - filtered: QtCore.SignalInstance = QtCore.Signal(bool) - - defaultColumnDelegates = {} - - class HeaderMenu(QtWidgets.QMenu): - def __init__(self, pos: QtCore.QPoint, view: "ABTreeView"): - super().__init__(view) - - model = view.model() - - col_index = view.columnAt(pos.x()) - col_name = model.columns()[col_index] - - search_box = ABLineEdit(self) - search_box.setText(view.columnFilters.get(col_name, "")) - search_box.setPlaceholderText("Search") - search_box.selectAll() - search_box.textChangedDebounce.connect(lambda query: view.setColumnFilter(col_name, query)) - widget_action = QtWidgets.QWidgetAction(self) - widget_action.setDefaultWidget(search_box) - self.addAction(widget_action) - - self.addAction(QtGui.QIcon(), "Group by column", lambda: model.group([col_name])) - self.addAction(QtGui.QIcon(), "Ungroup", model.ungroup) - self.addAction(QtGui.QIcon(), "Clear column filter", lambda: view.setColumnFilter(col_name, "")) - self.addAction(QtGui.QIcon(), "Clear all filters", - lambda: [view.setColumnFilter(name, "") for name in list(view.columnFilters.keys())], - ) - self.addSeparator() - - def toggle_slot(action: QtWidgets.QAction): - index = action.data() - hidden = view.isColumnHidden(index) - view.setColumnHidden(index, not hidden) - - view_menu = QtWidgets.QMenu(view) - view_menu.setTitle("View") - self.view_actions = [] - - for i in range(1, len(model.columns())): - action = QtWidgets.QAction(model.columns()[i]) - action.setCheckable(True) - action.setChecked(not view.isColumnHidden(i)) - action.setData(i) - view_menu.addAction(action) - self.view_actions.append(action) - - view_menu.triggered.connect(toggle_slot) - - self.addMenu(view_menu) - - search_box.setFocus() - - class ContextMenu(QtWidgets.QMenu): - def __init__(self, pos, view): - super().__init__(view) - - def __init__(self, parent=None): - from activity_browser.ui import delegates - - super().__init__(parent) - self.setIndentation(10) - self.setItemDelegate(delegates.StringDelegate(self)) - - self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - self.customContextMenuRequested.connect(self.showContextMenu) - - self.setSelectionBehavior(QtWidgets.QTreeView.SelectionBehavior.SelectRows) - self.setSelectionMode(QtWidgets.QTreeView.SelectionMode.ExtendedSelection) - - self.header().setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - self.header().customContextMenuRequested.connect(self.showHeaderMenu) - - self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) - - self.columnFilters: dict[str, str] = {} # dict[column_name, query] for filtering the dataframe - self.allFilter: str = "" # filter applied to the entire dataframe - - def setModel(self, model): - super().setModel(model) - - self.setColumnWidth(0, 20) - self.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Fixed) - - model.modelAboutToBeReset.connect(self.clearColumnDelegates) - model.modelReset.connect(self.updateIndexColumnVisibility) - model.modelReset.connect(self.setDefaultColumnDelegates) - model.modelReset.connect(self.updateBranchSpanning, QtCore.Qt.ConnectionType.QueuedConnection) - model.layoutChanged.connect(self.updateIndexColumnVisibility) - model.layoutChanged.connect(self.updateBranchSpanning, QtCore.Qt.ConnectionType.QueuedConnection) - model.rowsInserted.connect(self.updateBranchSpanningForInsertedRows, QtCore.Qt.ConnectionType.QueuedConnection) - - self.setDefaultColumnDelegates() - self.updateIndexColumnVisibility() - self.updateBranchSpanning() - - def model(self) -> core.ABTreeModel: - return super().model() - - # === Functionality related to contextmenus - - def showContextMenu(self, pos): - self.ContextMenu(pos, self).exec_(self.mapToGlobal(pos)) - - def showHeaderMenu(self, pos): - self.HeaderMenu(pos, self).exec_(self.mapToGlobal(pos)) - - def setColumnFilter(self, column_name: str, query: str): - """ - Set a filter for a specific column using a string query. If the query is empty remove the filter from the column - """ - col_index = self.model().columns().index(column_name) - - if query: - self.columnFilters[column_name] = query - self.model().filtered_columns.add(col_index) - elif column_name in self.columnFilters: - del self.columnFilters[column_name] - self.model().filtered_columns.discard(col_index) - - self.applyFilter() - - # === Functionality related to filtering - - def setAllFilter(self, query: str): - self.allFilter = query - self.applyFilter() - - def buildQuery(self) -> str: - queries = [] - - # query for the column filters - for col in list(self.columnFilters): - if col not in self.model().columns(): - del self.columnFilters[col] - - for col, query in self.columnFilters.items(): - q = f"({col}.astype('str').str.contains('{self.format_query(query)}', False))" - queries.append(q) - - # query for the all filter - if self.allFilter.startswith('='): - queries.append(f"({self.allFilter[1:]})") - else: - all_queries = [] - formatted_filter = self.format_query(self.allFilter) - - for i, col in enumerate(self.model().columns()): - if col == "index" or self.isColumnHidden(i): - continue - all_queries.append(f"(`{col}`.astype('str').str.contains('{formatted_filter}', False))") - - q = f"({' | '.join(all_queries)})" - queries.append(q) - - query = " & ".join(queries) - logger.debug(f"{self.__class__.__name__} built query: {query}") - - return query - - def applyFilter(self): - query = self.buildQuery() - try: - self.model().filter("ABTreeView", query) - self.filtered.emit(True) - except Exception as e: - logger.info(f"{self.__class__.__name__} {type(e).__name__} in query: {e}") - self.filtered.emit(False) - - @staticmethod - def format_query(query: str) -> str: - return query.translate(str.maketrans({'(': '\\(', ')': '\\)', "'": "\\'"})) - - # === Functionality related to setting the column delegates - def clearColumnDelegates(self): - for i in range(self.model().columnCount()): - self.setItemDelegateForColumn(i, None) - - def setDefaultColumnDelegates(self): - columns = self.model().columns() - for i, col_name in enumerate(columns): - if col_name in self.defaultColumnDelegates: - delegate = self.defaultColumnDelegates[col_name](self) - self.setItemDelegateForColumn(i, delegate) - elif col_name.startswith("property_"): - self.setItemDelegateForColumn(i, self.propertyDelegate) - - def updateIndexColumnVisibility(self): - """Hide the index column (column 0) if the dataframe index is only one level deep.""" - model = self.model() - if model is None: - return - - # Check if model has the df attribute (ABTreeModel style) - if hasattr(model, 'df') and hasattr(model.df, 'index'): - # Hide index column if it's only one level deep - hide_index = model.df.index.nlevels == 1 - self.setColumnHidden(0, hide_index) - - def updateBranchSpanning(self): - """Enable spanning for branch nodes so they span across all columns.""" - model = self.model() - if model is None or not hasattr(model, 'isBranchNode'): - return - - # Recursively set spanning for all branch nodes - self._setSpanningRecursive(QtCore.QModelIndex()) - - def updateBranchSpanningForInsertedRows(self, parent: QtCore.QModelIndex, first: int, last: int): - """Update spanning for newly inserted rows during lazy loading.""" - model = self.model() - if model is None or not hasattr(model, 'isBranchNode'): - return - - # Set spanning for the newly inserted rows - for row in range(first, last + 1): - index = model.index(row, 0, parent) - if not index.isValid(): - continue - - # Check if this is a branch node - if model.isBranchNode(index): - self.setFirstColumnSpanned(row, parent, True) - # Recursively process children of this branch node - self._setSpanningRecursive(index) - else: - self.setFirstColumnSpanned(row, parent, False) - - def _setSpanningRecursive(self, parent: QtCore.QModelIndex): - """Recursively set first column spanning for branch nodes.""" - model = self.model() - if model is None: - return - - row_count = model.rowCount(parent) - for row in range(row_count): - index = model.index(row, 0, parent) - if not index.isValid(): - continue - - # Check if this is a branch node - if hasattr(model, 'isBranchNode') and model.isBranchNode(index): - self.setFirstColumnSpanned(row, parent, True) - # Recursively process children - self._setSpanningRecursive(index) - else: - self.setFirstColumnSpanned(row, parent, False) - diff --git a/activity_browser/ui/widgets/web_engine_page.py b/activity_browser/ui/widgets/web_engine_page.py deleted file mode 100644 index 07f0a67ea..000000000 --- a/activity_browser/ui/widgets/web_engine_page.py +++ /dev/null @@ -1,15 +0,0 @@ -from loguru import logger - -from qtpy.QtWebEngineWidgets import QWebEnginePage - - -class ABWebEnginePage(QWebEnginePage): - def javaScriptConsoleMessage(self, level: QWebEnginePage.JavaScriptConsoleMessageLevel, message: str, line: str, _: str): - if level == QWebEnginePage.InfoMessageLevel: - logger.info(f"JS Info (Line {line}): {message}") - elif level == QWebEnginePage.WarningMessageLevel: - logger.warning(f"JS Warning (Line {line}): {message}") - elif level == QWebEnginePage.ErrorMessageLevel: - logger.error(f"JS Error (Line {line}): {message}") - else: - logger.debug(f"JS Log (Line {line}): {message}") diff --git a/activity_browser/ui/widgets/wizard.py b/activity_browser/ui/widgets/wizard.py index e5b599998..aa2834c8f 100644 --- a/activity_browser/ui/widgets/wizard.py +++ b/activity_browser/ui/widgets/wizard.py @@ -1,38 +1,17 @@ -from typing import TYPE_CHECKING, Literal -from qtpy import QtWidgets, QtCore +from typing import TYPE_CHECKING +from qtpy import QtWidgets if TYPE_CHECKING: from activity_browser.ui.widgets import ABWizardPage -ABWizardButtons = Literal[ - "Stretch", - "BackButton", - "NextButton", - "CancelButton", - "FinishButton", - "HelpButton", - "CommitButton", -] - -ABWizardButtonLayout = list[ABWizardButtons] - - class ABWizard(QtWidgets.QWizard): pages = [] - context = {} - defaultButtonLayout: ABWizardButtonLayout = ["Stretch", "BackButton", "NextButton", "CancelButton"] - finalButtonLayout: ABWizardButtonLayout = ["Stretch", "FinishButton"] def __init__(self, *args, title: str = None, context: dict = None, **kwargs): super().__init__(*args, **kwargs) self.setWizardStyle(QtWidgets.QWizard.WizardStyle.ModernStyle) - self.setWindowFlags( - QtCore.Qt.WindowType.Sheet | - QtCore.Qt.WindowType.CustomizeWindowHint | - QtCore.Qt.WindowType.WindowTitleHint - ) if title: self.setWindowTitle(title) @@ -40,18 +19,6 @@ def __init__(self, *args, title: str = None, context: dict = None, **kwargs): for page in self.pages: self.addPage(page(self)) - text, callback = self.customButtonOne() - self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton1, text) - self.button(QtWidgets.QWizard.WizardButton.CustomButton1).clicked.connect(callback) - - text, callback = self.customButtonTwo() - self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton2, text) - self.button(QtWidgets.QWizard.WizardButton.CustomButton2).clicked.connect(callback) - - text, callback = self.customButtonThree() - self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton3, text) - self.button(QtWidgets.QWizard.WizardButton.CustomButton3).clicked.connect(callback) - self.context = context or {} def page(self, page_id: int) -> "ABWizardPage": @@ -68,57 +35,3 @@ def initializePage(self, page_id): # initialize the next page page = self.page(page_id) page.initializePage(self.context) - - if page.buttonLayout: - if "CommitButton" in page.buttonLayout: - page.setCommitPage(True) - if "FinishButton" in page.buttonLayout: - page.setFinalPage(True) - - self.setButtonLayout(page.buttonLayout) - - elif self.currentId() == self.pageIds()[-1]: - self.setButtonLayout(self.finalButtonLayout) - - else: - self.setButtonLayout(self.defaultButtonLayout) - - def setButtonLayout(self, layout: ABWizardButtonLayout): - button_map = { - "Stretch": QtWidgets.QWizard.WizardButton.Stretch, - "BackButton": QtWidgets.QWizard.WizardButton.BackButton, - "NextButton": QtWidgets.QWizard.WizardButton.NextButton, - "CancelButton": QtWidgets.QWizard.WizardButton.CancelButton, - "FinishButton": QtWidgets.QWizard.WizardButton.FinishButton, - "HelpButton": QtWidgets.QWizard.WizardButton.HelpButton, - "CommitButton": QtWidgets.QWizard.WizardButton.CommitButton, - "CustomButton1": QtWidgets.QWizard.WizardButton.CustomButton1, - "CustomButton2": QtWidgets.QWizard.WizardButton.CustomButton2, - "CustomButton3": QtWidgets.QWizard.WizardButton.CustomButton3, - } - qt_layout = [button_map[item] for item in layout] - super().setButtonLayout(qt_layout) - - default_button = "NextButton" - default_button = "FinishButton" if "FinishButton" in layout else default_button - default_button = "CommitButton" if "CommitButton" in layout else default_button - - # Set the default button after a short delay to ensure the UI is updated - def set_default(): - try: - button = self.button(button_map[default_button]) - button.setFocus() - except RuntimeError: - # Wizard might be closed before the timer fires - pass - - QtCore.QTimer.singleShot(50, set_default) - - def customButtonOne(self): - return "CustomButton1", lambda: None - - def customButtonTwo(self): - return "CustomButton2", lambda: None - - def customButtonThree(self): - return "CustomButton3", lambda: None diff --git a/activity_browser/ui/widgets/wizard_page.py b/activity_browser/ui/widgets/wizard_page.py index 446617b9a..122ebd2c8 100644 --- a/activity_browser/ui/widgets/wizard_page.py +++ b/activity_browser/ui/widgets/wizard_page.py @@ -2,14 +2,13 @@ from qtpy import QtWidgets if TYPE_CHECKING: - from .wizard import ABWizard, ABWizardButtonLayout + from activity_browser.ui.widgets import ABWizard from activity_browser.ui.core.threading import ABThread class ABWizardPage(QtWidgets.QWizardPage): title: str = "" subtitle: str = "" - buttonLayout: "ABWizardButtonLayout" = [] def __init__(self, parent=None): super().__init__(parent) @@ -37,15 +36,12 @@ def initializePage(self, context: dict): def finalize(self, context: dict): pass - def context(self) -> dict: - return self.wizard().context - class ABThreadedWizardPage(ABWizardPage): Thread: type["ABThread"] def __init__(self, parent=None): - from activity_browser.app import application + from activity_browser import application super().__init__(parent) diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 30a08a194..000000000 --- a/docs/README.md +++ /dev/null @@ -1,205 +0,0 @@ -# docs - -Documentation for Activity Browser. - -## Overview - -This directory contains the source files for Activity Browser's documentation website, which is built using Jekyll and hosted on GitHub Pages. - -## Structure - -- **Jekyll Site Configuration** - - `_config.yml` - Jekyll site configuration - - `Gemfile` - Ruby gem dependencies - - `404.html` - Custom 404 error page - - `index.md` - Documentation homepage - -- **`_includes/`** - Reusable HTML/Liquid templates - - `nav_footer_custom.html` - Custom navigation footer - - `search_placeholder_custom.html` - Custom search placeholder - -- **`_sass/`** - SASS/CSS stylesheets - - `custom/` - Custom styling overrides - -- **`getting-started/`** - Getting started guides - - `installation.md` - Installation instructions - - `project-setup.md` - Setting up your first project - - `creating-databases.md` - Creating and managing databases - - `building-models.md` - Building LCA models - - `lca-calculations.md` - Running LCA calculations - - `index.md` - Getting started overview - -- **`user-interface/`** - UI documentation - - `pages/` - Documentation for each page - - `index.md` - UI overview - -- **`advanced-topics/`** - Advanced features - - `project-structure.md` - Understanding project structure - - `scenario-calculations.md` - Scenario analysis - - `brightway-legacy.md` - Working with Brightway legacy versions - - `multifunctional-databases/` - Multi-functionality documentation - - `index.md` - Advanced topics overview - -- **`assets/`** - Images, screenshots, and other assets - -- **`beta.md`** - Beta version information - -## Building Documentation - -### Prerequisites -- Ruby (for Jekyll) -- Bundler gem - -### Local Development - -1. Install dependencies: - ```bash - cd docs - bundle install - ``` - -2. Serve locally: - ```bash - bundle exec jekyll serve - ``` - -3. View at: `http://localhost:4000` - -### Live Documentation - -The documentation is automatically built and deployed to GitHub Pages when changes are pushed to the repository. - -URL: [https://lca-activitybrowser.github.io/activity-browser/](https://lca-activitybrowser.github.io/activity-browser/) - -## Writing Documentation - -### Markdown Files - -Documentation is written in Markdown with Jekyll front matter: - -```markdown ---- -layout: default -title: Page Title -nav_order: 1 ---- - -# Page Title - -Content goes here... -``` - -### Front Matter Options - -- **`layout`** - Page layout template (usually `default`) -- **`title`** - Page title -- **`nav_order`** - Navigation menu order -- **`parent`** - Parent page for nested navigation -- **`has_children`** - Whether page has child pages -- **`permalink`** - Custom URL path - -### Linking Pages - -Use relative links: -```markdown -See [Installation Guide]({% link getting-started/installation.md %}) -``` - -### Including Images - -Place images in `assets/` and reference: -```markdown -![Screenshot](../assets/screenshot.png) -``` - -### Code Blocks - -Use fenced code blocks with language: -```markdown -```python -import bw2data as bd -bd.projects.set_current("my_project") -``` -``` - -## Documentation Structure - -### Getting Started -Target audience: New users -- Installation -- First project -- Basic concepts -- First calculation - -### User Interface -Target audience: All users -- Navigation -- Pages and panes -- Common tasks -- Keyboard shortcuts - -### Advanced Topics -Target audience: Power users -- Scenarios and parameters -- Uncertainty analysis -- Sensitivity analysis -- Multi-functionality -- Integration with Brightway - -## Style Guide - -### Writing Style -- **Clear and concise** - Simple language -- **Task-oriented** - Focus on what users want to do -- **Step-by-step** - Break down complex tasks -- **Visual aids** - Screenshots and diagrams -- **Examples** - Show real examples - -### Formatting -- **Headings** - Use proper hierarchy (H1, H2, H3) -- **Lists** - For steps or multiple items -- **Bold** - For UI elements and important terms -- **Code** - For code, commands, and file paths -- **Notes/Tips** - Use blockquotes for callouts - -### Screenshots -- Use actual application screenshots -- Highlight relevant areas -- Keep up-to-date with current UI -- Crop to show only relevant content -- Use consistent window size - -## Maintenance - -### Keeping Current -- Update screenshots when UI changes -- Verify instructions after code changes -- Add documentation for new features -- Mark deprecated features -- Update version numbers - -### Review Process -- Test instructions on fresh install -- Check all links work -- Verify code examples -- Review for clarity -- Check mobile responsiveness - -## Contributing - -To contribute to documentation: - -1. Fork the repository -2. Create a branch for your changes -3. Edit/add Markdown files in `docs/` -4. Test locally with Jekyll -5. Submit a pull request - -See [CONTRIBUTING.md](../CONTRIBUTING.md) for more details. - -## Resources - -- [Jekyll Documentation](https://jekyllrb.com/docs/) -- [Just the Docs Theme](https://just-the-docs.github.io/just-the-docs/) -- [Markdown Guide](https://www.markdownguide.org/) -- [GitHub Pages](https://pages.github.com/) diff --git a/docs/img.png b/docs/img.png deleted file mode 100644 index 47db4e6ed483f49d93ac80330e5c9fc3e10ede8b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 576 zcmeAS@N?(olHy`uVBq!ia0y~yVD$pB>o}NzWG&yG`wR?B?4B-;Ar*0N4;nH81rIIw l>YvLIzW~S_1tTZ~;+QXJGcfW#INApCw5O||%Q~loCIE=0b97", "bw2analyzer>=0.11.5", "bw2calc>=2.0", "bw2data>=4.1", - "bw2io>=0.9.3", "bw2parameters>=1.1", + "bw2io>=0.9.3", "bw_graph_tools>=0.5", "bw_processing>=1.0", "bw_simapro_csv >=0.2.6", "ecoinvent_interface", - "loguru>=0.7", "matrix_utils>=0.5", + "bw-functional==0b94", "networkx", - "numpy>=1.23.5,<3", + "numpy>=1.23.5,<2", "pandas>=2.2.1", - "py7zr>=0.22.0", + "pint<=0.21", + "py7zr==0.22.0", "pyperclip", - "pyprind", "pyside6>=6.5.0, <6.10", + "pypardiso ; platform_system == 'Windows'", + "pyprind", "qtpy", "salib>=1.4", "seaborn", diff --git a/recipe/README.md b/recipe/README.md deleted file mode 100644 index bb76dc36b..000000000 --- a/recipe/README.md +++ /dev/null @@ -1,192 +0,0 @@ -# recipe - -Conda build recipe for Activity Browser. - -## Overview - -This directory contains the conda-build recipe for packaging and distributing Activity Browser via conda-forge. The recipe defines how to build the conda package from source. - -## Key File - -- **`meta.yaml`** - Conda package metadata and build instructions - -## meta.yaml Structure - -The `meta.yaml` file contains several sections: - -### Package Section -Defines package name and version: -```yaml -package: - name: activity-browser - version: {{ VERSION }} -``` - -### Source Section -Specifies where to get the source code: -```yaml -source: - path: .. # Local path for development - # Or from GitHub release: - # url: https://github.com/LCA-ActivityBrowser/activity-browser/archive/{{ version }}.tar.gz -``` - -### Build Section -Build configuration: -```yaml -build: - number: 0 - noarch: python # Pure Python package - entry_points: - - activity-browser = activity_browser:run_activity_browser -``` - -### Requirements Section -Dependencies for build and runtime: - -```yaml -requirements: - host: - - python >=3.9 - - pip - - setuptools - run: - - python >=3.9 - - brightway2 >=2.4 - - pyside6 >=6.0 - - qtpy >=2.0 - # ... more dependencies -``` - -### About Section -Package metadata: -```yaml -about: - home: https://github.com/LCA-ActivityBrowser/activity-browser - license: LGPL-3.0 - summary: GUI for Brightway2 LCA framework - description: Activity Browser is a GUI for the Brightway2 LCA framework - doc_url: https://lca-activitybrowser.github.io/activity-browser/ -``` - -## Building Locally - -### Prerequisites -- conda-build installed: `conda install conda-build` -- Conda environment set up - -### Build Command -```bash -conda build recipe/ -``` - -This will: -1. Create a clean build environment -2. Install dependencies -3. Build the package from source -4. Run tests -5. Create a conda package (.tar.bz2) - -### Build Variants -For different Python versions: -```bash -conda build recipe/ --python 3.9 -conda build recipe/ --python 3.10 -conda build recipe/ --python 3.11 -``` - -## conda-forge - -Activity Browser is distributed via conda-forge, the community-led conda package repository. - -### conda-forge Repository -The conda-forge recipe is maintained in a separate repository: -https://github.com/conda-forge/activity-browser-feedstock - -### Update Process -When a new version is released: -1. conda-forge bot detects new GitHub release -2. Opens PR to update version and SHA256 -3. Maintainers review and merge -4. Package is built for all platforms -5. Published to conda-forge channel - -### Maintainers -conda-forge package maintainers can: -- Update the recipe -- Adjust dependencies -- Fix build issues -- Release new versions - -## Installation - -Users install from conda-forge: -```bash -conda install -c conda-forge activity-browser -``` - -Or with mamba (faster): -```bash -mamba install -c conda-forge activity-browser -``` - -## Dependencies - -Keep dependencies in sync: -- `meta.yaml` (conda recipe) -- `pyproject.toml` (pip/setuptools) -- `setup.py` (legacy setup) - -Ensure all three specify the same dependencies and versions. - -## Platform Support - -Activity Browser supports: -- **Linux** - x86_64, aarch64 -- **macOS** - x86_64, arm64 (Apple Silicon) -- **Windows** - x86_64 - -The recipe should specify `noarch: python` if the package is pure Python, or include platform-specific builds if needed. - -## Troubleshooting - -### Build Failures -- Check dependency versions -- Verify source path/URL -- Review build logs -- Test in clean environment - -### Import Errors -- Missing dependencies in run requirements -- Incorrect entry points -- Module import issues - -### Test Failures -- Tests timing out -- Missing test dependencies -- Platform-specific issues - -## Development Workflow - -1. **Local Development** - - Edit source code - - Test locally with `python -m activity_browser` - -2. **Update Recipe** - - Modify `meta.yaml` if dependencies changed - - Update version number - -3. **Build and Test** - - Run `conda build recipe/` - - Install and test locally - -4. **Release** - - Tag release on GitHub - - conda-forge bot updates feedstock - - Package published automatically - -## Resources - -- [conda-build documentation](https://docs.conda.io/projects/conda-build/) -- [conda-forge documentation](https://conda-forge.org/docs/) -- [Activity Browser feedstock](https://github.com/conda-forge/activity-browser-feedstock) diff --git a/recipe/meta.yaml b/recipe/meta.yaml index 0323c6f47..494698ec5 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -20,17 +20,15 @@ requirements: run: - python >=3.10, <3.12 - arrow - - bw_functional>=0.b.97 - bw2analyzer >=0.11.5 - - bw2calc >=2.0 - bw2data >=4.1 - - bw2io >=0.9.3 - bw2parameters >=1.1 + - bw2io >=0.9.3 - bw_graph_tools >=0.5 - bw_processing >=1.0 - bw_simapro_csv >=0.2.6 + - bw_functional=0.b.94 - ecoinvent_interface - - loguru>=0.7 - matrix_utils >=0.5 - networkx - numpy >=1.23.5, <2 @@ -38,10 +36,12 @@ requirements: - py7zr >=0.22.0 - pyperclip - pyprind + - networkx + - pandas >=2.2.1 - pyside2 >=5.15.5 - qt-webengine - qtpy - - salib >=1.4 + - salib >=1.4, <1.5.1 - seaborn about: diff --git a/setup.py b/setup.py index 407e7b459..63ba3b2b9 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ setup( version=version, - packages=["activity_browser"], + packages=["activity_browser", "activity_browser_beta"], license=open("LICENSE.txt").read(), include_package_data=True, ) diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index cb1e925da..000000000 --- a/tests/README.md +++ /dev/null @@ -1,322 +0,0 @@ -# tests - -Test suite for Activity Browser. - -## Overview - -This directory contains the test suite for Activity Browser using pytest. Tests verify functionality, catch regressions, and ensure code quality across the application. - -## Test Framework - -**pytest** is used as the test runner with extensions: -- **pytest-qt** - Testing Qt applications -- **pytest-cov** - Coverage reporting -- **pytest-mock** - Mocking utilities - -## Directory Structure - -- **`actions/`** - Tests for action classes -- **`fixtures/`** - Test fixtures and mock data -- **`widgets/`** - Tests for UI widgets -- Additional test files for various modules - -## Key Files - -- **`conftest.py`** - Pytest configuration and shared fixtures -- **`test_search.py`** - Search engine tests - -## Running Tests - -### Run All Tests -```bash -pytest -``` - -### Run Specific Test File -```bash -pytest tests/test_search.py -``` - -### Run Specific Test -```bash -pytest tests/test_search.py::test_search_basic -``` - -### Run with Coverage -```bash -pytest --cov=activity_browser --cov-report=html -``` - -### Run in Parallel -```bash -pytest -n auto -``` - -## Test Categories - -### Unit Tests -Test individual functions and classes in isolation: -```python -def test_function(): - result = my_function(input_data) - assert result == expected_output -``` - -### Integration Tests -Test interaction between components: -```python -def test_database_import(): - # Test full import workflow - importer.load_file(test_file) - assert database_exists("test_db") -``` - -### UI Tests -Test Qt widgets and interactions: -```python -def test_button_click(qtbot): - widget = MyWidget() - qtbot.addWidget(widget) - qtbot.mouseClick(widget.button, Qt.LeftButton) - assert widget.clicked is True -``` - -### Action Tests -Test action classes: -```python -def test_delete_action(): - DeleteAction.run(item_key) - assert not item_exists(item_key) -``` - -## Fixtures - -Fixtures provide test data and setup (see `conftest.py` and `fixtures/`): - -### Common Fixtures -```python -@pytest.fixture -def sample_activity(): - """Provide a sample activity for testing.""" - return { - "name": "Test Activity", - "unit": "kg", - "location": "GLO" - } - -def test_with_fixture(sample_activity): - # Use fixture - assert sample_activity["unit"] == "kg" -``` - -### Brightway Fixtures -```python -@pytest.fixture -def bw_project(tmp_path): - """Create temporary Brightway project.""" - bd.projects.set_current("test_project") - yield - bd.projects.delete_project("test_project", delete_dir=True) -``` - -### Qt Fixtures -```python -@pytest.fixture -def qtbot(qtbot): - """Pytest-qt bot for widget testing.""" - return qtbot -``` - -## Writing Tests - -### Test Naming -- Test files: `test_*.py` or `*_test.py` -- Test functions: `test_*` -- Test classes: `Test*` - -### Test Structure -```python -def test_something(): - # Arrange - Set up test data - data = prepare_test_data() - - # Act - Execute the code being tested - result = function_under_test(data) - - # Assert - Verify the result - assert result == expected_value -``` - -### UI Test Example -```python -def test_widget_interaction(qtbot): - # Create widget - widget = MyWidget() - qtbot.addWidget(widget) - - # Simulate user input - qtbot.keyClicks(widget.input_field, "test text") - qtbot.mouseClick(widget.submit_button, Qt.LeftButton) - - # Verify result - assert widget.result_label.text() == "Success" -``` - -### Action Test Example -```python -def test_create_database_action(bw_project): - # Setup - db_name = "test_database" - - # Execute action - CreateDatabaseAction.run(db_name) - - # Verify - assert db_name in bd.databases -``` - -## Mocking - -Use mocks to isolate tests: - -```python -from unittest.mock import Mock, patch - -def test_with_mock(mocker): - # Mock external dependency - mock_api = mocker.patch("module.api_call") - mock_api.return_value = {"status": "success"} - - # Test code - result = my_function() - - # Verify mock was called - mock_api.assert_called_once() - assert result["status"] == "success" -``` - -## Testing Signals - -Test Qt signals and slots: - -```python -def test_signal_emission(qtbot): - widget = MyWidget() - - # Use signal spy - with qtbot.waitSignal(widget.data_changed, timeout=1000): - widget.modify_data() - - # Signal was emitted -``` - -## Testing Threads - -Test background operations: - -```python -def test_threaded_operation(qtbot): - widget = MyWidget() - - # Wait for thread to complete - with qtbot.waitSignal(widget.operation_complete, timeout=5000): - widget.start_operation() - - assert widget.result is not None -``` - -## Test Coverage - -Aim for high coverage: -- **Critical paths** - 100% coverage -- **Business logic** - >90% coverage -- **UI code** - >70% coverage -- **Utilities** - >80% coverage - -View coverage report: -```bash -pytest --cov=activity_browser --cov-report=html -open htmlcov/index.html -``` - -## Continuous Integration - -Tests run automatically on: -- Pull requests -- Commits to main branch -- Scheduled runs - -See `.github/workflows/main.yaml` for CI configuration. - -## Development Guidelines - -When writing tests: - -1. **Test behavior, not implementation** - Test what, not how -2. **One assertion per test** - Or at least one logical check -3. **Descriptive names** - Test names should explain what they test -4. **Independent tests** - Tests should not depend on each other -5. **Fast tests** - Keep tests quick (mock slow operations) -6. **Readable tests** - Tests are documentation -7. **Test edge cases** - Not just happy paths -8. **Use fixtures** - Reuse common setup -9. **Mock external dependencies** - Don't rely on network, files, etc. -10. **Clean up** - Use fixtures or teardown to clean up - -## Debugging Tests - -### Run with output -```bash -pytest -s # Show print statements -pytest -v # Verbose output -pytest -vv # Very verbose -``` - -### Run single test with debugger -```bash -pytest --pdb tests/test_file.py::test_function -``` - -### Show test durations -```bash -pytest --durations=10 # Slowest 10 tests -``` - -## Test Organization - -Group related tests: - -```python -class TestDatabaseOperations: - def test_create_database(self): - pass - - def test_delete_database(self): - pass - - def test_copy_database(self): - pass -``` - -Use parametrize for similar tests: - -```python -@pytest.mark.parametrize("input,expected", [ - (1, 2), - (2, 4), - (3, 6), -]) -def test_double(input, expected): - assert double(input) == expected -``` - -## Best Practices - -- **Test first** - Write tests before or alongside code -- **Small tests** - Each test should verify one thing -- **Clear assertions** - Make expected values obvious -- **No logic in tests** - Tests should be straightforward -- **Fail fast** - Catch issues early in the test -- **Document complex tests** - Add comments for clarity -- **Keep tests updated** - Refactor tests with code -- **Review test failures** - Don't ignore failing tests diff --git a/tests/actions/test_activity_actions.py b/tests/actions/test_activity_actions.py index 33c57dcf6..ffba13834 100644 --- a/tests/actions/test_activity_actions.py +++ b/tests/actions/test_activity_actions.py @@ -3,7 +3,7 @@ from bw2data.errors import BW2Exception from qtpy import QtWidgets -from activity_browser import app, app +from activity_browser import actions, application def test_activity_delete(monkeypatch, basic_database): @@ -16,7 +16,7 @@ def test_activity_delete(monkeypatch, basic_database): process = basic_database.get("process") - app.actions.ActivityDelete.run([process.key]) + actions.ActivityDelete.run([process.key]) assert len(basic_database) == 1 # removed process and products @@ -28,7 +28,7 @@ def test_activity_duplicate(basic_database): assert len(basic_database) == 4 process = basic_database.get("process") - app.actions.ActivityDuplicate.run([process.key]) + actions.ActivityDuplicate.run([process.key]) assert len(basic_database) == 7 @@ -42,13 +42,13 @@ def test_activity_duplicate(basic_database): # assert get_activity(key) # assert key not in panel.tabs # -# app.actions.ActivityGraph.run([key]) +# actions.ActivityGraph.run([key]) # # assert key in panel.tabs # # def test_activity_new(monkeypatch, basic_database): - from activity_browser.app.actions.activity.activity_new_process import NewNodeDialog + from activity_browser.ui.widgets.new_node_dialog import NewNodeDialog monkeypatch.setattr( NewNodeDialog, "exec_", staticmethod(lambda *args, **kwargs: True) @@ -62,7 +62,7 @@ def test_activity_new(monkeypatch, basic_database): assert len(basic_database) == 4 - app.actions.ActivityNewProcess.run(basic_database.name) + actions.ActivityNewProcess.run(basic_database.name) assert len(basic_database) == 6 assert len([p for p in basic_database if p["name"] == "new_process"]) == 2 @@ -72,16 +72,16 @@ def test_activity_new(monkeypatch, basic_database): def test_process_open(basic_database): process = basic_database.get("process") - app.actions.ActivityOpen.run([process.key]) + actions.ActivityOpen.run([process.key]) - group = app.main_window.centralWidget().groups["Activity Details"] + group = application.main_window.centralWidget().groups["Activity Details"] assert "activity_details_basic_process" in [group.widget(i).objectName() for i in range(group.count())] # def test_product_open(application_instance, basic_database): # product = basic_database.get("product_1") # -# app.actions.ActivityOpen.run([product.key]) +# actions.ActivityOpen.run([product.key]) # # group = application_instance.main_window.centralWidget().groups["Activity Details"] # assert "activity_details_basic_process" in [group.widget(i).objectName() for i in range(group.count())] @@ -104,6 +104,6 @@ def test_process_open(basic_database): # assert projects.current == "default" # assert list(get_activity(key).exchanges())[1].input.key == from_key # -# app.actions.ActivityRelink.run([key]) +# actions.ActivityRelink.run([key]) # # assert list(get_activity(key).exchanges())[1].input.key == to_key diff --git a/tests/actions/test_calculation_setup_actions.py b/tests/actions/test_calculation_setup_actions.py index 2b2eba52c..e8f40a946 100644 --- a/tests/actions/test_calculation_setup_actions.py +++ b/tests/actions/test_calculation_setup_actions.py @@ -1,7 +1,9 @@ +import pytest import bw2data as bd +from bw2data.errors import BW2Exception from qtpy import QtWidgets -from activity_browser import app +from activity_browser import actions @@ -18,7 +20,7 @@ def test_cs_delete(monkeypatch, basic_database): assert cs_name in bd.calculation_setups - app.actions.CSDelete.run(cs_name) + actions.CSDelete.run(cs_name) assert cs_name not in bd.calculation_setups @@ -36,7 +38,7 @@ def test_cs_duplicate(monkeypatch, basic_database): assert cs_name in bd.calculation_setups assert duplicated not in bd.calculation_setups - app.actions.CSDuplicate.run(cs_name) + actions.CSDuplicate.run(cs_name) assert cs_name in bd.calculation_setups assert duplicated in bd.calculation_setups @@ -53,7 +55,7 @@ def test_cs_new(monkeypatch, basic_database): assert new_cs not in bd.calculation_setups - app.actions.CSNew.run() + actions.CSNew.run() assert new_cs in bd.calculation_setups @@ -71,7 +73,7 @@ def test_cs_rename(monkeypatch, basic_database): assert cs_name in bd.calculation_setups assert renamed_cs not in bd.calculation_setups - app.actions.CSRename.run(cs_name) + actions.CSRename.run(cs_name) assert cs_name not in bd.calculation_setups assert renamed_cs in bd.calculation_setups diff --git a/tests/actions/test_database_actions.py b/tests/actions/test_database_actions.py index 5882d4178..5914362ac 100644 --- a/tests/actions/test_database_actions.py +++ b/tests/actions/test_database_actions.py @@ -1,7 +1,7 @@ import bw2data as bd from qtpy import QtWidgets -from activity_browser import app, app +from activity_browser import actions, application def test_database_delete(monkeypatch, basic_database): @@ -11,13 +11,13 @@ def test_database_delete(monkeypatch, basic_database): staticmethod(lambda *args, **kwargs: QtWidgets.QMessageBox.Yes), ) - app.actions.DatabaseDelete.run([basic_database.name]) + actions.DatabaseDelete.run([basic_database.name]) assert basic_database.name not in bd.databases def test_database_duplicate(monkeypatch, qtbot, basic_database): - from activity_browser.app.actions.database.database_duplicate import NewDatabaseDialog, DuplicateDatabaseDialog + from activity_browser.actions.database.database_duplicate import NewDatabaseDialog, DuplicateDatabaseDialog dup_db = "db_that_is_duplicated" @@ -29,9 +29,9 @@ def test_database_duplicate(monkeypatch, qtbot, basic_database): assert dup_db not in bd.databases - app.actions.DatabaseDuplicate.run(basic_database.name) + actions.DatabaseDuplicate.run(basic_database.name) - dialog = app.main_window.findChild(DuplicateDatabaseDialog) + dialog = application.main_window.findChild(DuplicateDatabaseDialog) with qtbot.waitSignal(dialog.dup_thread.finished, timeout=60 * 1000): pass @@ -41,7 +41,7 @@ def test_database_duplicate(monkeypatch, qtbot, basic_database): def test_database_export_excel(monkeypatch, qtbot, basic_database, tmp_path): """Test exporting a database to Excel format.""" - from activity_browser.app.actions.database.database_export_excel import ExportExcelSetup + from activity_browser.actions.database.database_export_excel import ExportExcelSetup # Mock the file dialog to return a path test_path = str(tmp_path / "test_export.xlsx") @@ -52,10 +52,10 @@ def test_database_export_excel(monkeypatch, qtbot, basic_database, tmp_path): ) # Call the action - app.actions.DatabaseExportExcel.run([basic_database.name]) + actions.DatabaseExportExcel.run([basic_database.name]) # Find the wizard dialog and wait for the export thread to finish - wizard = app.main_window.findChild(ExportExcelSetup) + wizard = application.main_window.findChild(ExportExcelSetup) assert wizard is not None # Wait for the export thread to finish @@ -69,7 +69,7 @@ def test_database_export_excel(monkeypatch, qtbot, basic_database, tmp_path): def test_database_export_bw2package(monkeypatch, qtbot, basic_database, tmp_path): """Test exporting a database to BW2Package format.""" - from activity_browser.app.actions.database.database_export_bw2package import ExportBW2PackageSetup + from activity_browser.actions.database.database_export_bw2package import ExportBW2PackageSetup # Mock the file dialog to return a path test_path = str(tmp_path / "test_export.bw2package") @@ -80,10 +80,10 @@ def test_database_export_bw2package(monkeypatch, qtbot, basic_database, tmp_path ) # Call the action - app.actions.DatabaseExportBW2Package.run([basic_database.name]) + actions.DatabaseExportBW2Package.run([basic_database.name]) # Find the wizard dialog and wait for the export thread to finish - wizard = app.main_window.findChild(ExportBW2PackageSetup) + wizard = application.main_window.findChild(ExportBW2PackageSetup) assert wizard is not None # Wait for the export thread to finish @@ -96,7 +96,7 @@ def test_database_export_bw2package(monkeypatch, qtbot, basic_database, tmp_path def test_database_new(monkeypatch, basic_database): - from activity_browser.app.actions.database.database_new import NewDatabaseDialog + from activity_browser.actions.database.database_new import NewDatabaseDialog new_db = "db_that_is_new" @@ -112,20 +112,20 @@ def test_database_new(monkeypatch, basic_database): assert new_db not in bd.databases - app.actions.DatabaseNew.run() + actions.DatabaseNew.run() assert new_db in bd.databases db_number = len(bd.databases) - app.actions.DatabaseNew.run() + actions.DatabaseNew.run() assert db_number == len(bd.databases) def test_database_delete_multiple(monkeypatch, basic_database): """Test that multiple databases can be deleted at once.""" - from activity_browser.app.actions.database.database_new import NewDatabaseDialog + from activity_browser.actions.database.database_new import NewDatabaseDialog # Create two additional databases db2 = "test_db_2" @@ -140,7 +140,7 @@ def test_database_delete_multiple(monkeypatch, basic_database): monkeypatch.setattr( QtWidgets.QMessageBox, "information", staticmethod(lambda *args, **kwargs: True) ) - app.actions.DatabaseNew.run() + actions.DatabaseNew.run() assert db2 in bd.databases assert db3 in bd.databases @@ -153,7 +153,7 @@ def test_database_delete_multiple(monkeypatch, basic_database): ) # Delete both databases at once - app.actions.DatabaseDelete.run([db2, db3]) + actions.DatabaseDelete.run([db2, db3]) assert db2 not in bd.databases assert db3 not in bd.databases @@ -180,7 +180,7 @@ def test_database_delete_multiple(monkeypatch, basic_database): # assert from_db in Database(db).find_dependents() # assert to_db not in Database(db).find_dependents() # -# app.actions.DatabaseRelink.run(db) +# actions.DatabaseRelink.run(db) # # assert db in databases # assert from_db in databases diff --git a/tests/actions/test_exchange_actions.py b/tests/actions/test_exchange_actions.py index 341b0ae17..632fff067 100644 --- a/tests/actions/test_exchange_actions.py +++ b/tests/actions/test_exchange_actions.py @@ -1,8 +1,8 @@ import pytest -from stats_arrays.distributions import NoUncertainty, UndefinedUncertainty, UniformUncertainty +from stats_arrays.distributions import NoUncertainty, UndefinedUncertainty -from activity_browser import app -from activity_browser.ui.dialogs import UncertaintyDialog +from activity_browser import actions, application +from activity_browser.ui.wizards import UncertaintyWizard # def test_exchange_copy_sdf(basic_database): @@ -26,7 +26,7 @@ # assert len(exchange) == 1 # assert clipboard.text() == "FAILED" # -# app.actions.ExchangeCopySDF.run(exchange) +# actions.ExchangeCopySDF.run(exchange) # # assert clipboard.text() != "FAILED" # @@ -46,7 +46,7 @@ def test_exchange_delete(basic_database): assert len(exchange) == 1 num_exchanges = len(process.exchanges()) - app.actions.ExchangeDelete.run(exchange) + actions.ExchangeDelete.run(exchange) assert len(process.exchanges()) == num_exchanges - 1 @@ -64,7 +64,7 @@ def test_exchange_formula_remove(basic_database): assert len(exchange) == 1 assert exchange[0].as_dict().get("formula") == "5+5" - app.actions.ExchangeFormulaRemove.run(exchange) + actions.ExchangeFormulaRemove.run(exchange) with pytest.raises(KeyError): assert exchange[0].as_dict()["formula"] @@ -85,7 +85,7 @@ def test_exchange_modify(basic_database): assert len(exchange) == 1 assert exchange[0].amount == 10.0 - app.actions.ExchangeModify.run(exchange[0], new_data) + actions.ExchangeModify.run(exchange[0], new_data) assert exchange[0].amount == 200.0 @@ -102,7 +102,7 @@ def test_exchange_new(basic_database): if exchange.input == other ] - app.actions.ExchangeNew.run([other.key], process.key, "technosphere") + actions.ExchangeNew.run([other.key], process.key, "technosphere") assert ( len( @@ -116,7 +116,7 @@ def test_exchange_new(basic_database): ) -def test_exchange_uncertainty_modify(monkeypatch, basic_database): +def test_exchange_uncertainty_modify(basic_database): process = basic_database.get("process") elementary = basic_database.get("elementary") @@ -126,35 +126,14 @@ def test_exchange_uncertainty_modify(monkeypatch, basic_database): if exchange.input == elementary ] assert len(exchange) == 1 - - # Initial state: should have NoUncertainty - assert exchange[0].uncertainty_type == NoUncertainty - - # Create mock uncertainty data to be returned by the dialog - mock_uncertainty = { - "uncertainty type": UniformUncertainty.id, - "loc": float("nan"), - "scale": float("nan"), - "shape": float("nan"), - "minimum": 5.0, - "maximum": 15.0, - "negative": False, - } - - # Monkeypatch the dialog to return our mock data - monkeypatch.setattr( - UncertaintyDialog, - "get_uncertainty_dict", - lambda *args, **kwargs: (True, mock_uncertainty), - ) - app.actions.ExchangeUncertaintyModify.run(exchange) + actions.ExchangeUncertaintyModify.run(exchange) + + wizard = application.main_window.findChild(UncertaintyWizard) + + assert wizard.isVisible() - # Verify the exchange was updated with the new uncertainty values - assert exchange[0].uncertainty_type == UniformUncertainty - assert exchange[0]["minimum"] == 5.0 - assert exchange[0]["maximum"] == 15.0 - assert exchange[0]["negative"] == False + wizard.destroy() def test_exchange_uncertainty_remove(basic_database): @@ -170,6 +149,6 @@ def test_exchange_uncertainty_remove(basic_database): assert exchange[0].uncertainty_type == NoUncertainty - app.actions.ExchangeUncertaintyRemove.run(exchange) + actions.ExchangeUncertaintyRemove.run(exchange) assert exchange[0].uncertainty_type == UndefinedUncertainty diff --git a/tests/actions/test_method_actions.py b/tests/actions/test_method_actions.py index 81e7921f7..284d94b93 100644 --- a/tests/actions/test_method_actions.py +++ b/tests/actions/test_method_actions.py @@ -5,9 +5,11 @@ from stats_arrays.distributions import ( NoUncertainty, UndefinedUncertainty, + UniformUncertainty, ) -from activity_browser import app +from activity_browser import actions +from activity_browser.ui.wizards import UncertaintyWizard def test_cf_amount_modify(basic_database): @@ -19,7 +21,7 @@ def test_cf_amount_modify(basic_database): assert len(cf) == 1 assert cf[0][1] == 1.0 or cf[0][1]["amount"] == 1.0 - app.actions.CFAmountModify.run(method, elementary.id, 200) + actions.CFAmountModify.run(method, elementary.id, 200) cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] assert cf[0][1] == 200.0 or cf[0][1]["amount"] == 200.0 @@ -34,7 +36,7 @@ def test_cf_new(basic_database): cf = [cf for cf in Method(method).load() if cf[0] == new_elementary.id] assert len(cf) == 0 - app.actions.CFNew.run(method, [new_elementary.key]) + actions.CFNew.run(method, [new_elementary.key]) cf = [cf for cf in Method(method).load() if cf[0] == new_elementary.id] @@ -55,7 +57,7 @@ def test_cf_remove(monkeypatch, basic_database): assert len(cf) == 1 - app.actions.CFRemove.run(method, cf) + actions.CFRemove.run(method, cf) cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] assert len(cf) == 0 @@ -82,14 +84,14 @@ def test_cf_remove(monkeypatch, basic_database): # assert len(cf) == 1 # assert cf[0][1].get("uncertainty type") == NoUncertainty.id # -# app.actions.CFUncertaintyModify.run(method, cf) +# actions.CFUncertaintyModify.run(method, cf) # # wizard = application_instance.main_window.findChild(UncertaintyWizard) # # assert wizard.isVisible() # # wizard.destroy() -# app.actions.CFUncertaintyModify.wizard_done(method, new_cf_tuple, uncertainty) +# actions.CFUncertaintyModify.wizard_done(method, new_cf_tuple, uncertainty) # # cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] # @@ -105,7 +107,7 @@ def test_cf_uncertainty_remove(basic_database): assert cf[0][1].get("uncertainty type") == NoUncertainty.id - app.actions.CFUncertaintyRemove.run(method, cf) + actions.CFUncertaintyRemove.run(method, cf) cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] assert ( @@ -124,13 +126,13 @@ def test_method_delete(monkeypatch, basic_database): assert method in methods - app.actions.MethodDelete.run([method]) + actions.MethodDelete.run([method]) assert method not in methods def test_method_duplicate(monkeypatch, basic_database): - from activity_browser.app.actions.method.method_duplicate import TupleNameDialog + from activity_browser.actions.method.method_duplicate import TupleNameDialog method = ("basic_method",) duplicated_method = ("basic_method - Copy",) @@ -146,14 +148,14 @@ def test_method_duplicate(monkeypatch, basic_database): assert method in methods assert duplicated_method not in methods - app.actions.MethodDuplicate.run([method], "leaf") + actions.MethodDuplicate.run([method], "leaf") assert method in methods assert duplicated_method in methods def test_method_new(monkeypatch, basic_database): - from activity_browser.ui.dialogs import ABListEditDialog + from activity_browser.ui.widgets import ABListEditDialog new_method = ("New Test Method", "Test Category") @@ -172,7 +174,7 @@ def test_method_new(monkeypatch, basic_database): assert new_method not in methods - app.actions.MethodNew.run() + actions.MethodNew.run() assert new_method in methods @@ -202,7 +204,7 @@ def test_calculation_setups_updated_on_method_delete(monkeypatch, basic_database staticmethod(lambda *args, **kwargs: QtWidgets.QMessageBox.Yes), ) - app.actions.MethodDelete.run([method]) + actions.MethodDelete.run([method]) # method removed assert method not in bw_methods @@ -213,7 +215,7 @@ def test_calculation_setups_updated_on_method_delete(monkeypatch, basic_database def test_calculation_setups_updated_on_method_rename(monkeypatch, basic_database): # prepare rename dialog to accept and return new name - from activity_browser.ui.dialogs import ABListEditDialog + from activity_browser.ui.widgets import ABListEditDialog import bw2data as bd old = ("basic_method",) @@ -236,7 +238,7 @@ def test_calculation_setups_updated_on_method_rename(monkeypatch, basic_database staticmethod(lambda *args, **kwargs: new), ) - app.actions.MethodRename.run(old) + actions.MethodRename.run(old) # setups reference the new method name cs = bd.calculation_setups["basic_calculation_setup"] diff --git a/tests/conftest.py b/tests/conftest.py index 7939d4477..f2e36a1e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,19 +1,14 @@ from copy import deepcopy -from importlib import reload -from loguru import logger -import pandas as pd import pytest -import os import bw2data as bd -from PySide6 import QtCore - import bw_functional as bf from bw2data.tests import bw2test -os.environ["AB_SKIP_SETTINGS_ON_STARTUP"] = "1" -os.environ["AB_NO_SEARCHER"] = "1" +from activity_browser import application +from activity_browser.ui.widgets import MainWindow, CentralTabWidget +from activity_browser.layouts import pages @pytest.fixture @@ -26,42 +21,32 @@ def no_exception_dialogs(monkeypatch): # No need to undo the monkeypatch, pytest does it automatically -@pytest.fixture +@pytest.fixture() def main_window(qtbot, monkeypatch, no_exception_dialogs): """Return the main window of the application instance.""" - from activity_browser import app - from activity_browser.bwutils.metadata import metadata + main_window = MainWindow() + central_widget = CentralTabWidget(main_window) - # Reload modules to ensure a clean state for each test - reload(metadata) - reload(app.main) - reload(app) - metadata.dataframe = pd.DataFrame() + qtbot.addWidget(main_window) + setattr(application, "main_window", main_window) - app.main_window.show() + central_widget.addTab(pages.WelcomePage(), "Welcome") + central_widget.addTab(pages.ParametersPage(), "Parameters") - yield main_window + main_window.setCentralWidget(central_widget) + main_window.show() - app.main_window.deleteLater() + yield main_window + # main_window.close() + main_window.deleteLater() qtbot.wait(10) @pytest.fixture @bw2test -def basic_database(qapp, main_window): - import time - from activity_browser.app import metadata +def basic_database(main_window): from fixtures.basic import DATABASE, METHOD, CALCULATION_SETUP - qapp.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) - - i = 0 - while metadata.loader.secondary_status != "done" and i < 60: - logger.warning("Waiting for project load to finish") - time.sleep(1) - qapp.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) - i += 1 - db = bf.FunctionalSQLiteDatabase("basic") db.write(deepcopy(DATABASE), process=False) db.metadata["dirty"] = True @@ -74,15 +59,5 @@ def basic_database(qapp, main_window): bd.calculation_setups["basic_calculation_setup"] = CALCULATION_SETUP bd.calculation_setups.flush() - i = 0 - while metadata.loader.secondary_status != "done" and i < 60: - logger.warning("Waiting for database load to finish...") - time.sleep(1) - qapp.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) - i += 1 - - if i >= 60: - raise TimeoutError("Metadata loader did not finish in time.") - - yield db + return db diff --git a/tests/test_mds_cross_database.py b/tests/test_mds_cross_database.py deleted file mode 100644 index b183759cd..000000000 --- a/tests/test_mds_cross_database.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Tests for MDSSearcher cross-database functionality.""" -import pytest -import pandas as pd -from activity_browser.bwutils.metadata.searcher import MDSSearcher -from activity_browser.bwutils.metadata.metadata import MetaDataStore - - -@pytest.fixture -def multi_db_mds(): - """Create a MetaDataStore with multiple databases.""" - test_data = pd.DataFrame([ - [1, "db1", "coal production", "coal", "process", "", "", "", ""], - [2, "db1", "coal mining", "coal", "process", "", "", "", ""], - [3, "db1", "steel production", "steel", "process", "", "", "", ""], - [4, "db2", "coal transport", "transport", "process", "", "", "", ""], - [5, "db2", "electricity from coal", "electricity", "process", "", "", "", ""], - [6, "db3", "coal combustion", "heat", "process", "", "", "", ""], - [7, "db3", "gas production", "gas", "process", "", "", "", ""], - ], columns=["id", "database", "name", "reference product", "type", "location", "unit", "comment", "tags"]) - - mds = MetaDataStore() - mds.dataframe = test_data - return mds - - -def test_search_single_database(multi_db_mds): - """Test searching within a single database.""" - searcher = MDSSearcher(multi_db_mds) - - # Search for "coal" in db1 - results = searcher.search("coal", database="db1") - assert len(results) == 2 - assert set(results) == {1, 2} - - # Search for "coal" in db2 - results = searcher.search("coal", database="db2") - assert len(results) == 2 - assert set(results) == {4, 5} - - # Search for "coal" in db3 - results = searcher.search("coal", database="db3") - assert len(results) == 1 - assert set(results) == {6} - - -def test_search_all_databases(multi_db_mds): - """Test searching across all databases when database=None.""" - searcher = MDSSearcher(multi_db_mds) - - # Search for "coal" across all databases - results = searcher.search("coal", database=None) - assert len(results) == 5 - assert set(results) == {1, 2, 4, 5, 6} - - # Search for "production" across all databases - results = searcher.search("production", database=None) - assert len(results) == 3 - assert set(results) == {1, 3, 7} - - -def test_fuzzy_search_all_databases(multi_db_mds): - """Test fuzzy search across all databases.""" - searcher = MDSSearcher(multi_db_mds) - - # Fuzzy search for "coal" across all databases - results = searcher.fuzzy_search("coal", database=None) - assert len(results) >= 5 - - # Fuzzy search for "production" across all databases - results = searcher.fuzzy_search("production", database=None) - assert len(results) >= 3 - - -def test_search_cache_separation(multi_db_mds): - """Test that search cache properly separates single-db and all-db searches.""" - searcher = MDSSearcher(multi_db_mds) - - # Do searches to populate cache - results_db1 = searcher.search("coal", database="db1") - results_all = searcher.search("coal", database=None) - - # Verify results are different - assert len(results_db1) == 2 - assert len(results_all) == 5 - assert set(results_db1).issubset(set(results_all)) - - # Search again to use cached results - results_3ached = searcher.search("coal", database="db1") - results_all_cached = searcher.search("coal", database=None) - - # Verify cached results match original - assert results_db1 == results_3ached - assert results_all == results_all_cached - - -def test_auto_complete_all_databases(multi_db_mds): - """Test autocomplete across all databases.""" - searcher = MDSSearcher(multi_db_mds) - - # Autocomplete for "coa" across all databases - completions = searcher.auto_complete("coa", database=None) - assert "coal" in completions - - # Autocomplete for "prod" in specific database - completions_db1 = searcher.auto_complete("prod", database="db1") - assert "production" in completions_db1 - - # Autocomplete for "prod" across all databases - completions_all = searcher.auto_complete("prod", database=None) - assert "production" in completions_all - - -def test_empty_search_all_databases(multi_db_mds): - """Test empty search returns all items when database=None.""" - searcher = MDSSearcher(multi_db_mds) - - results = searcher.search("", database=None) - assert len(results) == 7 # All items in all databases - diff --git a/tests/test_search.py b/tests/test_search.py deleted file mode 100644 index 58e344037..000000000 --- a/tests/test_search.py +++ /dev/null @@ -1,239 +0,0 @@ -import pytest -import pandas as pd -from activity_browser.bwutils.searchengine import SearchEngine - - -def data_for_test(): - return pd.DataFrame([ - ["a", "coal production", "coal"], - ["b", "coal production", "something"], - ["c", "coal production", "coat"], - ["d", "coal hello production", "something"], - ["e", "dont zzfind me", "hello world"], - ["f", "coat", "zzanother word"], - ["g", "coalispartofthisword", "things"], - ["h", "coal", "coal"], - ], - columns = ["id", "col1", "col2"]) - - -# test standard init -def test_search_init(): - """Do initialization tests.""" - df = data_for_test() - - # init search class with non-existent identifier col and fail - with pytest.raises(Exception): - _ = SearchEngine(df, identifier_name="non_existent_col_name") - # init search class with non-unique identifiers and fail - df2 = df.copy() - df2.iloc[0, 0] = "b" - with pytest.raises(Exception): - _ = SearchEngine(df2, identifier_name="id") - # init search class correctly - se = SearchEngine(df, identifier_name="id") - - -# test internals -def test_reverse_dict(): - """Do test to reverse the special Counter dict.""" - df = data_for_test() - se = SearchEngine(df, identifier_name="id") - - # reverse once and verify - w2i = se.reverse_dict_many_to_one(se.identifier_to_word) - assert w2i == se.word_to_identifier - - # reverse again and verify is same as original - i2w = se.reverse_dict_many_to_one(w2i) - assert i2w == se.identifier_to_word - - -def test_string_distance(): - """Do tests specifically for string distance function.""" - df = data_for_test() - se = SearchEngine(df, identifier_name="id") - - # same word - assert se.osa_distance("coal", "coal") == 0 - # empty string is length of other word - assert se.osa_distance("coal", "") == 4 - - # insert - assert se.osa_distance("coal", "coa") == 1 - # delete - assert se.osa_distance("coal", "coall") == 1 - # substitute - assert se.osa_distance("coal", "coat") == 1 - # transpose - assert se.osa_distance("coal", "cola") == 1 - - # longer edit distance - assert se.osa_distance("coal", "chocolate") == 6 - # reverse order gives same result - assert se.osa_distance("coal", "chocolate") == se.osa_distance("chocolate", "coal") - # cutoff - assert se.osa_distance("coal", "chocolate", cutoff=5, cutoff_return=1000) == 1000 - assert se.osa_distance("coal", "chocolate", cutoff=6, cutoff_return=1000) == 1000 - assert se.osa_distance("coal", "chocolate", cutoff=7, cutoff_return=1000) == 6 - # length cutoff - assert se.osa_distance("coal", "coallongword") == 8 - assert se.osa_distance("coal", "coallongword", cutoff=5, cutoff_return=1000) == 1000 - - # two entirely different words (test of early stopping) - assert se.osa_distance("brown", "jumped") == 6 - assert se.osa_distance("brown", "jumped", cutoff=6, cutoff_return=1000) == 1000 - assert se.osa_distance("brown", "jumped", cutoff=7, cutoff_return=1000) == 6 - - -# test functionality -def test_in_index(): - """Do checks for checking if word is in the index.""" - df = data_for_test() - se = SearchEngine(df, identifier_name="id") - - # use string with space - with pytest.raises(Exception): - se.word_in_index("coal and space") - - assert se.word_in_index("coal") - assert not se.word_in_index("coa") - - -def test_spellcheck(): - """Do checks spell checking.""" - df = data_for_test() - se = SearchEngine(df, identifier_name="id") - - checked = se.spell_check("coa productions something flintstones") - # coal HAS to be first, it is found more often in the data - assert checked["coa"] == ["coal", "coat"] - # find production - assert checked["productions"] == ["production"] - # should be empty as there is no alternative (but this word occurs) - assert checked["something"] == [] - # should be empty as there is no alternative (does not exist) - assert checked["flintstones"] == [] - - -def test_search_base(): - """Do checks for correct search ranking.""" - - df = data_for_test() - - # init search class and two searches - se = SearchEngine(df, identifier_name="id") - # do search on specific term - assert se.search("coal") == ["a", "h", "c", "b", "d", "g", "f"] - # do search on other term - assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] - # do search on typo - assert se.search("cola") == ["a", "c", "h", "b", "d", "f", "g"] - # do search on longer typo - assert se.search("cola production") == ["c", "a", "b", "d", "h", "f", "g"] - # do search on something we will definitely not find - assert se.search("dontFindThis") == [] - - # init search class with 1 col searchable - se = SearchEngine(df, identifier_name="id", searchable_columns=["col2"]) - assert se.search("coal") == ["a", "h", "c"] - - -def test_search_add_identifier(): - """Do tests for adding identifier.""" - df = data_for_test() - - # create base item to add - new_base_item = pd.DataFrame([ - ["i", "coal production", "coal production"], - ], - columns=["id", "col1", "col2"]) - - # use existing identifier and fail - se = SearchEngine(df, identifier_name="id") - wrong_id = new_base_item.copy() - wrong_id.iloc[0, 0] = "a" - with pytest.raises(Exception): - se.add_identifier(wrong_id) - - # add data without identifier column - se = SearchEngine(df, identifier_name="id") - no_id = new_base_item.copy() - del no_id["id"] - with pytest.raises(Exception): - se.add_identifier(no_id) - - # use column more (and find data in new col) - se = SearchEngine(df, identifier_name="id") - col_more = new_base_item.copy() - col_more["col3"] = ["potatoes"] - se.add_identifier(col_more) - assert se.search("potatoes") == ["i"] - - # use column less (should be filled with empty string) - se = SearchEngine(df, identifier_name="id") - col_less = new_base_item.copy() - del col_less["col2"] - se.add_identifier(col_less) - assert se.df.loc["i", "col2"] == "" - - # do search, add item and verify results are different - se = SearchEngine(df, identifier_name="id") - assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] - se.add_identifier(new_base_item) - assert se.search("coal production") == ["i", "a", "c", "b", "d", "h", "f", "g"] - - -def test_search_remove_identifier(caplog): - """Do tests for removing identifier.""" - caplog.set_level("WARNING") - df = data_for_test() - - # do search, remove item and verify results are different - se = SearchEngine(df, identifier_name="id") - assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] - se.remove_identifier(identifier="a") - assert se.search("coal production") == ["c", "b", "d", "h", "f", "g"] - - # now search on something only in a column we later remove - assert se.search("find") == ["e"] - se.remove_identifier(identifier="e") - assert se.search("find") == [] - - -def test_search_change_identifier(): - """Do tests for changing identifier.""" - df = data_for_test() - - # create base item to add - edit_data = pd.DataFrame([ - ["a", "cant find me anymore", "something different"], - ], - columns=["id", "col1", "col2"]) - - # use non-existent identifier and fail - se = SearchEngine(df, identifier_name="id") - missing_id = edit_data.copy() - missing_id["id"] = ["i"] - with pytest.raises(Exception): - se.change_identifier(identifier="i", data=missing_id) - - # use mismatched identifier and fail - se = SearchEngine(df, identifier_name="id") - wrong_id = edit_data.copy() - wrong_id["id"] = ["i"] - with pytest.raises(Exception): - se.change_identifier(identifier="a", data=wrong_id) - - # do search, change item and verify results are different - se = SearchEngine(df, identifier_name="id") - assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] - se.change_identifier(identifier="a", data=edit_data) - assert se.search("coal production") == ["c", "b", "d", "h", "f", "g"] - # now change the same item partially and verify results are different - new_edit_data = pd.DataFrame([ - ["a", "coal"], - ], - columns=["id", "col1"]) - se.change_identifier(identifier="a", data=new_edit_data) - assert se.search("coal production") == ["c", "b", "d", "h", "a", "f", "g"] From 997daac58e3b2e4f7a4aea0ce741c43587cc7ba2 Mon Sep 17 00:00:00 2001 From: bsteubing Date: Sun, 22 Mar 2026 17:53:44 -0500 Subject: [PATCH 267/267] Restore commit for 6cb20346dbeb74fbd2ced7feb27e8b1cd7f544c8 --- .github/workflows/README.md | 192 ++ .github/workflows/build-executable.yml | 45 + .github/workflows/install-canary.yaml | 101 +- .github/workflows/testing.yaml | 4 +- activity_browser/README.md | 35 + activity_browser/__init__.py | 22 +- activity_browser/__main__.py | 109 +- activity_browser/app/README.md | 67 + activity_browser/app/__init__.py | 31 + activity_browser/app/actions/README.md | 92 + activity_browser/app/actions/__init__.py | 100 + .../app/actions/activity/activity_delete.py | 97 + .../actions/activity/activity_duplicate.py | 26 + .../activity/activity_duplicate_to_db.py | 158 ++ .../app/actions/activity/activity_modify.py | 27 + .../actions/activity/activity_new_process.py | 130 + .../actions/activity/activity_new_product.py | 154 ++ .../app/actions/activity/activity_open.py | 63 + .../app/actions/activity/activity_relink.py | 220 ++ .../activity/activity_sdf_to_clipboard.py | 37 + .../activity/process_property_modify.py | 136 + .../activity/process_property_remove.py | 60 + activity_browser/app/actions/base.py | 61 + .../cs_add_functional_unit.py | 23 + .../cs_add_impact_category.py | 21 + .../actions/calculation_setup/cs_calculate.py | 76 + .../cs_change_functional_unit.py | 40 + .../actions/calculation_setup/cs_delete.py | 47 + .../cs_delete_functional_unit.py | 22 + .../cs_delete_impact_category.py | 22 + .../actions/calculation_setup/cs_duplicate.py | 49 + .../app/actions/calculation_setup/cs_new.py | 91 + .../app/actions/calculation_setup/cs_open.py | 25 + .../actions/calculation_setup/cs_rename.py | 49 + .../app/actions/database/database_delete.py | 96 + .../actions/database/database_duplicate.py | 102 + .../database/database_explorer_open.py | 21 + .../database/database_export_bw2package.py | 99 + .../actions/database/database_export_excel.py | 95 + .../database_import_from_ecoinvent.py | 477 ++++ .../database/database_importer_bw2package.py | 90 + .../database/database_importer_excel.py | 211 ++ .../app/actions/database/database_new.py | 111 + .../app/actions/database/database_open.py | 64 + .../app/actions/database/database_process.py | 19 + .../app/actions/database/database_relink.py | 273 ++ .../actions/database/database_set_readonly.py | 33 + .../app/actions/exchange/exchange_copy_sdf.py | 23 + .../app/actions/exchange/exchange_delete.py | 19 + .../exchange/exchange_formula_remove.py | 27 + .../app/actions/exchange/exchange_modify.py | 57 + .../app/actions/exchange/exchange_new.py | 23 + .../exchange/exchange_sdf_to_clipboard.py | 34 + .../exchange/exchange_uncertainty_modify.py | 34 + .../exchange/exchange_uncertainty_remove.py | 23 + .../app/actions/metadatastore_cache_clear.py | 20 + .../app/actions/metadatastore_open.py | 21 + .../app/actions/method/cf_amount_modify.py | 29 + activity_browser/app/actions/method/cf_new.py | 46 + .../app/actions/method/cf_remove.py | 42 + .../actions/method/cf_uncertainty_modify.py | 47 + .../actions/method/cf_uncertainty_remove.py | 40 + .../method/importer/method_importer_bw2io.py | 59 + .../importer/method_importer_ecoinvent.py | 158 ++ .../app/actions/method/method_delete.py | 93 + .../app/actions/method/method_duplicate.py | 149 ++ .../app/actions/method/method_meta_modify.py | 25 + .../app/actions/method/method_new.py | 77 + .../app/actions/method/method_open.py | 28 + .../app/actions/method/method_rename.py | 112 + .../app/actions/migrations_install.py | 41 + .../app/actions/node_select_open.py | 29 + .../parameter/parameter_clear_broken.py | 38 + .../app/actions/parameter/parameter_delete.py | 67 + .../parameter/parameter_group_delete.py | 50 + .../app/actions/parameter/parameter_modify.py | 57 + .../app/actions/parameter/parameter_new.py | 208 ++ .../parameter/parameter_new_automatic.py | 51 + .../parameter/parameter_new_from_parameter.py | 61 + .../app/actions/parameter/parameter_rename.py | 48 + .../parameter/parameter_uncertainty_modify.py | 36 + .../parameter/parameter_uncertainty_remove.py | 22 + .../project/project_create_template.py | 93 + .../app/actions/project/project_delete.py | 134 + .../app/actions/project/project_duplicate.py | 65 + .../app/actions/project/project_export.py | 81 + .../app/actions/project/project_import.py | 116 + .../actions/project/project_local_import.py | 278 ++ .../actions/project/project_manager_open.py | 26 + .../app/actions/project/project_migrate25.py | 164 ++ .../app/actions/project/project_new.py | 49 + .../app/actions/project/project_new_remote.py | 65 + .../actions/project/project_new_template.py | 87 + .../actions/project/project_remote_import.py | 318 +++ .../app/actions/project/project_switch.py | 118 + .../app/actions/pyside_upgrade.py | 102 + .../app/actions/save_parameters_to_excel.py | 39 + .../tools/bw2io/tools_bw2io_migrations.py | 19 + activity_browser/app/dialogs/README.md | 13 + activity_browser/app/dialogs/__init__.py | 3 + .../app/dialogs/database_select_dialog.py | 35 + .../dialogs/import_preview_dialog/__init__.py | 1 + .../dialogs/import_preview_dialog/edge_tab.py | 253 ++ .../import_preview_dialog.py | 30 + .../dialogs/import_preview_dialog/node_tab.py | 172 ++ .../app/dialogs/node_select_dialog.py | 196 ++ activity_browser/app/main.py | 282 ++ activity_browser/app/menu_bar.py | 336 +++ activity_browser/app/pages/README.md | 88 + activity_browser/app/pages/__init__.py | 14 + .../app/pages/activity_details/__init__.py | 1 + .../activity_details/activity_details.py | 169 ++ .../pages/activity_details/activity_header.py | 310 +++ .../pages/activity_details/consumers_tab.py | 163 ++ .../app/pages/activity_details/data_tab.py | 189 ++ .../pages/activity_details/description_tab.py | 51 + .../pages/activity_details/exchanges_tab.py | 834 ++++++ .../app/pages/activity_details/graph_tab.py | 333 +++ .../pages/activity_details/parameters_tab.py | 387 +++ .../app/pages/calculation_setup/__init__.py | 1 + .../calculation_setup/calculation_setup.py | 93 + .../functional_unit_section.py | 241 ++ .../impact_category_section.py | 98 + .../calculation_setup/scenario_section.py | 580 +++++ .../pages/impact_category_details/__init__.py | 1 + .../impact_category_details.py | 307 +++ .../impact_category_header.py | 192 ++ .../app/pages/lca_results/LCA_results.py | 2277 +++++++++++++++++ .../app/pages/lca_results/__init__.py | 1 + .../app/pages/lca_results/dialogs.py | 574 +++++ .../app/pages/lca_results/plots.py | 309 +++ .../app/pages/lca_results/sankey_navigator.py | 473 ++++ .../app/pages/lca_results/style.py | 25 + .../app/pages/lca_results/tables.py | 989 +++++++ .../app/pages/lca_results/tree_navigator.py | 506 ++++ activity_browser/app/pages/metadatastore.py | 36 + .../app/pages/parameters/__init__.py | 2 + .../parameterized_exchanges_section.py | 296 +++ .../app/pages/parameters/parameters.py | 65 + .../pages/parameters/parameters_section.py | 443 ++++ activity_browser/app/pages/settings/README.md | 194 ++ .../app/pages/settings/__init__.py | 18 + .../app/pages/settings/appearance.py | 98 + activity_browser/app/pages/settings/base.py | 39 + .../app/pages/settings/metadatastore.py | 125 + .../app/pages/settings/plugins.py | 166 ++ .../app/pages/settings/project_manager.py | 227 ++ .../app/pages/settings/settings_page.py | 159 ++ .../app/pages/settings/startup.py | 218 ++ activity_browser/app/pages/welcome.py | 75 + activity_browser/app/panes/README.md | 75 + activity_browser/app/panes/__init__.py | 10 + .../app/panes/calculation_setups.py | 202 ++ .../app/panes/database_products.py | 608 +++++ activity_browser/app/panes/databases.py | 327 +++ .../app/panes/impact_categories.py | 178 ++ activity_browser/app/signalling.py | 328 +++ activity_browser/bwutils/README.md | 56 + activity_browser/bwutils/__init__.py | 15 - activity_browser/bwutils/commontasks.py | 97 +- .../ecospold2biosphereimporter.py | 27 +- activity_browser/bwutils/exporters.py | 3 +- activity_browser/bwutils/filesystem.py | 25 + activity_browser/bwutils/importers.py | 41 +- .../bwutils/io/ecoinvent_importer.py | 10 +- activity_browser/bwutils/metadata/README.md | 54 + activity_browser/bwutils/metadata/__init__.py | 4 +- activity_browser/bwutils/metadata/fields.py | 23 +- activity_browser/bwutils/metadata/loader.py | 299 ++- activity_browser/bwutils/metadata/metadata.py | 231 +- activity_browser/bwutils/metadata/searcher.py | 486 ++++ activity_browser/bwutils/metadata/updater.py | 114 +- activity_browser/bwutils/montecarlo.py | 22 +- activity_browser/bwutils/multilca.py | 39 +- .../bwutils/searchengine/__init__.py | 2 + activity_browser/bwutils/searchengine/base.py | 779 ++++++ .../bwutils/searchengine/metadata_search.py | 447 ++++ .../bwutils/sensitivity_analysis.py | 41 +- activity_browser/bwutils/settings.py | 110 + activity_browser/bwutils/strategies.py | 49 +- .../bwutils/superstructure/dataframe.py | 16 +- .../bwutils/superstructure/excel.py | 8 +- .../bwutils/superstructure/file_dialogs.py | 7 +- .../bwutils/superstructure/file_imports.py | 10 +- .../bwutils/superstructure/manager.py | 12 +- .../bwutils/superstructure/mlca.py | 4 +- .../bwutils/superstructure/utils.py | 6 +- activity_browser/bwutils/utils.py | 14 +- activity_browser/info.py | 61 +- activity_browser/mod/README.md | 58 + activity_browser/mod/bw2io/__init__.py | 20 +- activity_browser/mod/bw2io/ecoinvent.py | 20 +- .../bw2io/importers/ecospold2_biosphere.py | 21 +- activity_browser/static/README.md | 63 + activity_browser/static/css/README.md | 245 ++ activity_browser/static/icons/README.md | 214 ++ .../static/icons/exchanges/link.png | Bin 0 -> 31393 bytes .../static/icons/exchanges/relink.png | Bin 0 -> 32995 bytes .../static/icons/exchanges/unlink.png | Bin 0 -> 34142 bytes .../static/icons/main/activitybrowser.ico | Bin 0 -> 124236 bytes activity_browser/static/icons/main/star.png | Bin 0 -> 1187 bytes activity_browser/ui/README.md | 86 + activity_browser/ui/core/README.md | 178 ++ activity_browser/ui/core/__init__.py | 1 + activity_browser/ui/core/application.py | 121 + activity_browser/ui/core/threading.py | 40 +- activity_browser/ui/core/tree_model.py | 585 +++++ activity_browser/ui/delegates/README.md | 138 + activity_browser/ui/delegates/__init__.py | 6 +- activity_browser/ui/delegates/card.py | 192 ++ activity_browser/ui/delegates/new_formula.py | 7 +- activity_browser/ui/delegates/string.py | 1 + activity_browser/ui/delegates/uncertainty.py | 54 +- activity_browser/ui/dialogs/README.md | 269 ++ activity_browser/ui/dialogs/__init__.py | 4 + .../ui/dialogs/list_edit_dialog.py | 363 +++ .../ui/dialogs/progress_dialog.py | 26 + .../ui/dialogs/uncertainty_dialog.py | 483 ++++ activity_browser/ui/icons.py | 136 +- activity_browser/ui/widgets/README.md | 202 ++ activity_browser/ui/widgets/__init__.py | 16 +- .../ui/widgets/abstract_navigator.py | 218 ++ activity_browser/ui/widgets/abstract_page.py | 8 + activity_browser/ui/widgets/buttons.py | 63 + activity_browser/ui/widgets/central.py | 36 +- activity_browser/ui/widgets/cutoff_menu.py | 4 +- .../ui/widgets/database_name_edit.py | 3 +- activity_browser/ui/widgets/dock_widget.py | 87 +- activity_browser/ui/widgets/drop_overlay.py | 52 +- activity_browser/ui/widgets/formula_edit.py | 52 +- activity_browser/ui/widgets/line_edit.py | 22 +- activity_browser/ui/widgets/menu.py | 12 +- activity_browser/ui/widgets/plot.py | 64 + activity_browser/ui/widgets/tab_widget.py | 61 + activity_browser/ui/widgets/text_edit.py | 255 ++ activity_browser/ui/widgets/tree_view.py | 259 ++ .../ui/widgets/web_engine_page.py | 15 + activity_browser/ui/widgets/wizard.py | 91 +- activity_browser/ui/widgets/wizard_page.py | 8 +- docs/README.md | 205 ++ docs/getting-started/installation.md | 4 +- docs/img.png | Bin 0 -> 576 bytes pyinstaller.spec | 74 + pyproject.toml | 7 +- recipe/README.md | 192 ++ recipe/meta.yaml | 9 +- setup.py | 2 +- tests/README.md | 322 +++ tests/actions/test_activity_actions.py | 20 +- .../actions/test_calculation_setup_actions.py | 12 +- tests/actions/test_database_actions.py | 36 +- tests/actions/test_exchange_actions.py | 53 +- tests/actions/test_method_actions.py | 32 +- tests/conftest.py | 59 +- tests/test_mds_cross_database.py | 119 + tests/test_search.py | 239 ++ 256 files changed, 30639 insertions(+), 904 deletions(-) create mode 100644 .github/workflows/README.md create mode 100644 .github/workflows/build-executable.yml create mode 100644 activity_browser/README.md create mode 100644 activity_browser/app/README.md create mode 100644 activity_browser/app/__init__.py create mode 100644 activity_browser/app/actions/README.md create mode 100644 activity_browser/app/actions/__init__.py create mode 100644 activity_browser/app/actions/activity/activity_delete.py create mode 100644 activity_browser/app/actions/activity/activity_duplicate.py create mode 100644 activity_browser/app/actions/activity/activity_duplicate_to_db.py create mode 100644 activity_browser/app/actions/activity/activity_modify.py create mode 100644 activity_browser/app/actions/activity/activity_new_process.py create mode 100644 activity_browser/app/actions/activity/activity_new_product.py create mode 100644 activity_browser/app/actions/activity/activity_open.py create mode 100644 activity_browser/app/actions/activity/activity_relink.py create mode 100644 activity_browser/app/actions/activity/activity_sdf_to_clipboard.py create mode 100644 activity_browser/app/actions/activity/process_property_modify.py create mode 100644 activity_browser/app/actions/activity/process_property_remove.py create mode 100644 activity_browser/app/actions/base.py create mode 100644 activity_browser/app/actions/calculation_setup/cs_add_functional_unit.py create mode 100644 activity_browser/app/actions/calculation_setup/cs_add_impact_category.py create mode 100644 activity_browser/app/actions/calculation_setup/cs_calculate.py create mode 100644 activity_browser/app/actions/calculation_setup/cs_change_functional_unit.py create mode 100644 activity_browser/app/actions/calculation_setup/cs_delete.py create mode 100644 activity_browser/app/actions/calculation_setup/cs_delete_functional_unit.py create mode 100644 activity_browser/app/actions/calculation_setup/cs_delete_impact_category.py create mode 100644 activity_browser/app/actions/calculation_setup/cs_duplicate.py create mode 100644 activity_browser/app/actions/calculation_setup/cs_new.py create mode 100644 activity_browser/app/actions/calculation_setup/cs_open.py create mode 100644 activity_browser/app/actions/calculation_setup/cs_rename.py create mode 100644 activity_browser/app/actions/database/database_delete.py create mode 100644 activity_browser/app/actions/database/database_duplicate.py create mode 100644 activity_browser/app/actions/database/database_explorer_open.py create mode 100644 activity_browser/app/actions/database/database_export_bw2package.py create mode 100644 activity_browser/app/actions/database/database_export_excel.py create mode 100644 activity_browser/app/actions/database/database_import_from_ecoinvent.py create mode 100644 activity_browser/app/actions/database/database_importer_bw2package.py create mode 100644 activity_browser/app/actions/database/database_importer_excel.py create mode 100644 activity_browser/app/actions/database/database_new.py create mode 100644 activity_browser/app/actions/database/database_open.py create mode 100644 activity_browser/app/actions/database/database_process.py create mode 100644 activity_browser/app/actions/database/database_relink.py create mode 100644 activity_browser/app/actions/database/database_set_readonly.py create mode 100644 activity_browser/app/actions/exchange/exchange_copy_sdf.py create mode 100644 activity_browser/app/actions/exchange/exchange_delete.py create mode 100644 activity_browser/app/actions/exchange/exchange_formula_remove.py create mode 100644 activity_browser/app/actions/exchange/exchange_modify.py create mode 100644 activity_browser/app/actions/exchange/exchange_new.py create mode 100644 activity_browser/app/actions/exchange/exchange_sdf_to_clipboard.py create mode 100644 activity_browser/app/actions/exchange/exchange_uncertainty_modify.py create mode 100644 activity_browser/app/actions/exchange/exchange_uncertainty_remove.py create mode 100644 activity_browser/app/actions/metadatastore_cache_clear.py create mode 100644 activity_browser/app/actions/metadatastore_open.py create mode 100644 activity_browser/app/actions/method/cf_amount_modify.py create mode 100644 activity_browser/app/actions/method/cf_new.py create mode 100644 activity_browser/app/actions/method/cf_remove.py create mode 100644 activity_browser/app/actions/method/cf_uncertainty_modify.py create mode 100644 activity_browser/app/actions/method/cf_uncertainty_remove.py create mode 100644 activity_browser/app/actions/method/importer/method_importer_bw2io.py create mode 100644 activity_browser/app/actions/method/importer/method_importer_ecoinvent.py create mode 100644 activity_browser/app/actions/method/method_delete.py create mode 100644 activity_browser/app/actions/method/method_duplicate.py create mode 100644 activity_browser/app/actions/method/method_meta_modify.py create mode 100644 activity_browser/app/actions/method/method_new.py create mode 100644 activity_browser/app/actions/method/method_open.py create mode 100644 activity_browser/app/actions/method/method_rename.py create mode 100644 activity_browser/app/actions/migrations_install.py create mode 100644 activity_browser/app/actions/node_select_open.py create mode 100644 activity_browser/app/actions/parameter/parameter_clear_broken.py create mode 100644 activity_browser/app/actions/parameter/parameter_delete.py create mode 100644 activity_browser/app/actions/parameter/parameter_group_delete.py create mode 100644 activity_browser/app/actions/parameter/parameter_modify.py create mode 100644 activity_browser/app/actions/parameter/parameter_new.py create mode 100644 activity_browser/app/actions/parameter/parameter_new_automatic.py create mode 100644 activity_browser/app/actions/parameter/parameter_new_from_parameter.py create mode 100644 activity_browser/app/actions/parameter/parameter_rename.py create mode 100644 activity_browser/app/actions/parameter/parameter_uncertainty_modify.py create mode 100644 activity_browser/app/actions/parameter/parameter_uncertainty_remove.py create mode 100644 activity_browser/app/actions/project/project_create_template.py create mode 100644 activity_browser/app/actions/project/project_delete.py create mode 100644 activity_browser/app/actions/project/project_duplicate.py create mode 100644 activity_browser/app/actions/project/project_export.py create mode 100644 activity_browser/app/actions/project/project_import.py create mode 100644 activity_browser/app/actions/project/project_local_import.py create mode 100644 activity_browser/app/actions/project/project_manager_open.py create mode 100644 activity_browser/app/actions/project/project_migrate25.py create mode 100644 activity_browser/app/actions/project/project_new.py create mode 100644 activity_browser/app/actions/project/project_new_remote.py create mode 100644 activity_browser/app/actions/project/project_new_template.py create mode 100644 activity_browser/app/actions/project/project_remote_import.py create mode 100644 activity_browser/app/actions/project/project_switch.py create mode 100644 activity_browser/app/actions/pyside_upgrade.py create mode 100644 activity_browser/app/actions/save_parameters_to_excel.py create mode 100644 activity_browser/app/actions/tools/bw2io/tools_bw2io_migrations.py create mode 100644 activity_browser/app/dialogs/README.md create mode 100644 activity_browser/app/dialogs/__init__.py create mode 100644 activity_browser/app/dialogs/database_select_dialog.py create mode 100644 activity_browser/app/dialogs/import_preview_dialog/__init__.py create mode 100644 activity_browser/app/dialogs/import_preview_dialog/edge_tab.py create mode 100644 activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py create mode 100644 activity_browser/app/dialogs/import_preview_dialog/node_tab.py create mode 100644 activity_browser/app/dialogs/node_select_dialog.py create mode 100644 activity_browser/app/main.py create mode 100644 activity_browser/app/menu_bar.py create mode 100644 activity_browser/app/pages/README.md create mode 100644 activity_browser/app/pages/__init__.py create mode 100644 activity_browser/app/pages/activity_details/__init__.py create mode 100644 activity_browser/app/pages/activity_details/activity_details.py create mode 100644 activity_browser/app/pages/activity_details/activity_header.py create mode 100644 activity_browser/app/pages/activity_details/consumers_tab.py create mode 100644 activity_browser/app/pages/activity_details/data_tab.py create mode 100644 activity_browser/app/pages/activity_details/description_tab.py create mode 100644 activity_browser/app/pages/activity_details/exchanges_tab.py create mode 100644 activity_browser/app/pages/activity_details/graph_tab.py create mode 100644 activity_browser/app/pages/activity_details/parameters_tab.py create mode 100644 activity_browser/app/pages/calculation_setup/__init__.py create mode 100644 activity_browser/app/pages/calculation_setup/calculation_setup.py create mode 100644 activity_browser/app/pages/calculation_setup/functional_unit_section.py create mode 100644 activity_browser/app/pages/calculation_setup/impact_category_section.py create mode 100644 activity_browser/app/pages/calculation_setup/scenario_section.py create mode 100644 activity_browser/app/pages/impact_category_details/__init__.py create mode 100644 activity_browser/app/pages/impact_category_details/impact_category_details.py create mode 100644 activity_browser/app/pages/impact_category_details/impact_category_header.py create mode 100644 activity_browser/app/pages/lca_results/LCA_results.py create mode 100644 activity_browser/app/pages/lca_results/__init__.py create mode 100644 activity_browser/app/pages/lca_results/dialogs.py create mode 100644 activity_browser/app/pages/lca_results/plots.py create mode 100644 activity_browser/app/pages/lca_results/sankey_navigator.py create mode 100644 activity_browser/app/pages/lca_results/style.py create mode 100644 activity_browser/app/pages/lca_results/tables.py create mode 100644 activity_browser/app/pages/lca_results/tree_navigator.py create mode 100644 activity_browser/app/pages/metadatastore.py create mode 100644 activity_browser/app/pages/parameters/__init__.py create mode 100644 activity_browser/app/pages/parameters/parameterized_exchanges_section.py create mode 100644 activity_browser/app/pages/parameters/parameters.py create mode 100644 activity_browser/app/pages/parameters/parameters_section.py create mode 100644 activity_browser/app/pages/settings/README.md create mode 100644 activity_browser/app/pages/settings/__init__.py create mode 100644 activity_browser/app/pages/settings/appearance.py create mode 100644 activity_browser/app/pages/settings/base.py create mode 100644 activity_browser/app/pages/settings/metadatastore.py create mode 100644 activity_browser/app/pages/settings/plugins.py create mode 100644 activity_browser/app/pages/settings/project_manager.py create mode 100644 activity_browser/app/pages/settings/settings_page.py create mode 100644 activity_browser/app/pages/settings/startup.py create mode 100644 activity_browser/app/pages/welcome.py create mode 100644 activity_browser/app/panes/README.md create mode 100644 activity_browser/app/panes/__init__.py create mode 100644 activity_browser/app/panes/calculation_setups.py create mode 100644 activity_browser/app/panes/database_products.py create mode 100644 activity_browser/app/panes/databases.py create mode 100644 activity_browser/app/panes/impact_categories.py create mode 100644 activity_browser/app/signalling.py create mode 100644 activity_browser/bwutils/README.md create mode 100644 activity_browser/bwutils/filesystem.py create mode 100644 activity_browser/bwutils/metadata/README.md create mode 100644 activity_browser/bwutils/metadata/searcher.py create mode 100644 activity_browser/bwutils/searchengine/__init__.py create mode 100644 activity_browser/bwutils/searchengine/base.py create mode 100644 activity_browser/bwutils/searchengine/metadata_search.py create mode 100644 activity_browser/bwutils/settings.py create mode 100644 activity_browser/mod/README.md create mode 100644 activity_browser/static/README.md create mode 100644 activity_browser/static/css/README.md create mode 100644 activity_browser/static/icons/README.md create mode 100644 activity_browser/static/icons/exchanges/link.png create mode 100644 activity_browser/static/icons/exchanges/relink.png create mode 100644 activity_browser/static/icons/exchanges/unlink.png create mode 100644 activity_browser/static/icons/main/activitybrowser.ico create mode 100644 activity_browser/static/icons/main/star.png create mode 100644 activity_browser/ui/README.md create mode 100644 activity_browser/ui/core/README.md create mode 100644 activity_browser/ui/core/application.py create mode 100644 activity_browser/ui/core/tree_model.py create mode 100644 activity_browser/ui/delegates/README.md create mode 100644 activity_browser/ui/delegates/card.py create mode 100644 activity_browser/ui/dialogs/README.md create mode 100644 activity_browser/ui/dialogs/__init__.py create mode 100644 activity_browser/ui/dialogs/list_edit_dialog.py create mode 100644 activity_browser/ui/dialogs/progress_dialog.py create mode 100644 activity_browser/ui/dialogs/uncertainty_dialog.py create mode 100644 activity_browser/ui/widgets/README.md create mode 100644 activity_browser/ui/widgets/abstract_navigator.py create mode 100644 activity_browser/ui/widgets/abstract_page.py create mode 100644 activity_browser/ui/widgets/buttons.py create mode 100644 activity_browser/ui/widgets/plot.py create mode 100644 activity_browser/ui/widgets/tab_widget.py create mode 100644 activity_browser/ui/widgets/text_edit.py create mode 100644 activity_browser/ui/widgets/tree_view.py create mode 100644 activity_browser/ui/widgets/web_engine_page.py create mode 100644 docs/README.md create mode 100644 docs/img.png create mode 100644 pyinstaller.spec create mode 100644 recipe/README.md create mode 100644 tests/README.md create mode 100644 tests/test_mds_cross_database.py create mode 100644 tests/test_search.py diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 000000000..407cee17a --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,192 @@ +# GitHub Actions Workflows + +This document describes the GitHub Actions workflows used in the Activity Browser project. + +## Overview + +The Activity Browser project uses five GitHub Actions workflows to automate testing, deployment, and project management tasks: + +1. **Automated Testing** - Runs tests on every push and PR +2. **Canary Installation** - Daily installation checks to catch dependency issues +3. **Beta Deployment** - Publishes beta releases to PyPI and Anaconda +4. **Stable Release** - Creates releases and publishes to Anaconda +5. **Milestone Comments** - Automatically notifies users when issues are resolved in releases + +--- + +## 1. Automated Testing (`testing.yaml`) + +**Trigger:** Push or pull request to the `major` branch + +**Purpose:** Ensures code quality by running the test suite across multiple operating systems and Python versions. + +### Matrix Strategy +- **Operating Systems:** Ubuntu (latest), Windows (latest), macOS 15, macOS (latest) +- **Python Versions:** 3.10, 3.11, 3.12 +- **Total combinations:** 12 test runs per trigger + +### Steps +1. Checkout code +2. Set up Python for the specified version +3. Install Qt libraries (Linux only) +4. Update pip, setuptools, and wheel +5. Install package with testing dependencies: `pip install .[testing]` +6. Run pytest with minimal output: `pytest -s --no-header --no-summary -q` + +### Environment +- Sets `QT_QPA_PLATFORM=offscreen` for headless GUI testing +- Uses `fail-fast: false` to run all combinations even if some fail + +--- + +## 2. Canary Installation (`install-canary.yaml`) + +**Trigger:** Scheduled daily at 7:00 AM UTC (cron: `0 7 * * *`) + +**Purpose:** Proactively detects dependency issues by performing fresh installations of Activity Browser from PyPI daily. + +### Matrix Strategy +- **Operating Systems:** Ubuntu (latest), Windows (latest), macOS 15, macOS (latest) +- **Python Versions:** 3.10, 3.11, 3.12 +- **Timeout:** 12 minutes per job + +### Steps +1. Checkout code +2. Set up Python +3. Install activity-browser from PyPI (not from source) +4. Generate environment info with `pip freeze` +5. Upload frozen requirements as artifact for each OS/Python combination + +### Notes +- Uses `bash -e {0}` shell to exit on error +- Helps catch breaking changes in dependencies before users encounter them +- Artifacts show exact dependency versions that successfully installed + +--- + +## 3. Beta Deployment (`python-package-deploy.yml`) + +**Trigger:** Push to `beta` branch or any tag + +**Purpose:** Publishes beta versions to PyPI (test and production) and Anaconda Cloud. + +### Version Scheme +- Beta version format: `3.0.0b` where N is the commit count since commit `199b6c3` +- Calculated dynamically: `git rev-list 199b6c3..HEAD --count` + +### Steps +1. Checkout with full git history (`fetch-depth: "0"`) +2. Calculate and set version number +3. Set up Python 3.11 +4. Install `build` package +5. Build wheel and source distribution +6. **PyPI Publishing:** + - Publish to Test PyPI (with `skip-existing: true`) + - Publish to production PyPI +7. **Conda Publishing:** + - Set up Conda environment from `.github/conda-envs/build.yml` + - Build Conda package: `conda build -c conda-forge -c cmutel ./recipe/` + - Upload to Anaconda Cloud using `CONDA_LCA` secret token + +### Permissions +- Requires `id-token: write` for PyPI trusted publishing + +--- + +## 4. Stable Release (`release.yaml`) + +**Trigger:** Push of any git tag + +**Purpose:** Creates GitHub releases with auto-generated changelogs and publishes stable versions to Anaconda. + +### Steps +1. Checkout code +2. **Generate Changelog:** + - Uses `mikepenz/release-changelog-builder-action@v4` + - Configuration from `.github/changelog-configuration.json` + - Builds changelog from PRs with labels +3. **Create GitHub Release:** + - Uses `ncipollo/release-action@v1` + - Includes generated changelog as release notes + - Targets `main` branch commit +4. **Build and Upload Conda Package:** + - Set up Conda environment (Python 3.11) + - Build with `conda build recipe/` + - Upload to Anaconda using `CONDA_UPLOAD_TOKEN` secret +5. **Update Wiki:** + - Runs `.github/scripts/update_wiki.sh` to automatically update documentation + +### Notes +- Only runs on tagged commits (version releases) +- Creates public GitHub releases visible to users +- Updates project wiki documentation automatically + +--- + +## 5. Milestone Comments (`comment-milestoned-issues.yaml`) + +**Trigger:** When a milestone is closed + +**Purpose:** Automatically notifies users on closed issues when their issue has been implemented in a release. + +### Steps +1. Uses `actions/github-script@v5` to run JavaScript automation +2. Gets milestone number and title from the event +3. Lists all issues associated with the milestone +4. For each closed issue (not PRs): + - Posts a comment with: + - Link to the new release + - Instructions to update Activity Browser + - Link to subscribe to the updates mailing list + - Bot disclaimer + +### Comment Template +The bot posts a formatted note: +- Informs that the issue is implemented in version X +- Provides update instructions +- Offers subscription to updates mailing list (brightway.groups.io) +- Includes bot identification + +--- + +## Workflow Dependencies + +### Secrets Required +- `GITHUB_TOKEN` - Automatically provided by GitHub Actions +- `CONDA_LCA` - Anaconda upload token for beta releases +- `CONDA_UPLOAD_TOKEN` - Anaconda upload token for stable releases + +### Configuration Files +- `.github/conda-envs/build.yml` - Conda environment for building packages +- `.github/changelog-configuration.json` - Changelog generation configuration +- `.github/scripts/update_wiki.sh` - Wiki update script +- `recipe/meta.yaml` - Conda package recipe +- `pyproject.toml` - Python package configuration + +--- + +## Development Notes + +### Running Tests Locally +To run the same tests that CI runs: +```bash +pip install .[testing] +pytest -s --no-header --no-summary -q +``` + +### Testing Matrix Changes +When modifying the test matrix (OS or Python versions): +- Update both `testing.yaml` and `install-canary.yaml` to keep them in sync +- Consider the maintenance burden of additional combinations +- Current support: Python 3.10-3.12, Ubuntu/Windows/macOS + +### Release Process +1. **Beta release:** Push to `beta` branch → Auto-publishes beta version +2. **Stable release:** Create and push a tag → Creates GitHub release and publishes to Anaconda +3. **Close milestone:** When closing a milestone → Users get notified automatically + +### Monitoring +- Check daily canary runs to catch dependency issues +- Review failed test runs in PR checks before merging +- Monitor PyPI and Anaconda Cloud for successful uploads + diff --git a/.github/workflows/build-executable.yml b/.github/workflows/build-executable.yml new file mode 100644 index 000000000..688a9011a --- /dev/null +++ b/.github/workflows/build-executable.yml @@ -0,0 +1,45 @@ +name: Build Executable +on: + push: + branches: [ major ] + tags: '*' + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest, macos-latest, macos-15] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.12 + + - name: Install UV + uses: astral-sh/setup-uv@v2 + + - name: Sync dependencies + shell: bash + run: | + uv add pyinstaller + uv sync --prerelease=allow + + - name: Build executable with PyInstaller + shell: bash + run: | + uv run pyinstaller pyinstaller.spec + + - uses: actions/upload-artifact@v4 + with: + name: activity-browser-${{ matrix.os }} + path: dist/* \ No newline at end of file diff --git a/.github/workflows/install-canary.yaml b/.github/workflows/install-canary.yaml index 066bb8958..fccc5d456 100644 --- a/.github/workflows/install-canary.yaml +++ b/.github/workflows/install-canary.yaml @@ -3,113 +3,16 @@ on: schedule: # Run the tests once every 24 hours to catch dependency problems early - cron: '0 7 * * *' - push: - branches: - - install-canary jobs: - canary-installs: - timeout-minutes: 12 - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-13] - python-version: ["3.10", "3.11"] - defaults: - run: - shell: bash -l {0} - steps: - - name: Setup python ${{ matrix.python-version }} conda environment - uses: conda-incubator/setup-miniconda@v3 - with: - python-version: ${{ matrix.python-version }} - miniconda-version: "latest" - - name: Install activity-browser - run: | - conda create -y -n ab -c conda-forge --solver libmamba activity-browser python=${{ matrix.python-version }} - - name: Environment info - run: | - conda activate ab - conda list - conda env export - conda env export -f env.yaml - - name: Upload final environment as artifact - uses: actions/upload-artifact@v4 - with: - name: env-${{ matrix.os }}-${{ matrix.python-version }} - path: env.yaml - - # also run install with micromamba instead of conda to have a timing comparison - canary-installs-mamba: - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.11'] - defaults: - run: - shell: bash -l {0} - steps: - - name: Setup python ${{ matrix.python-version }} conda environment - uses: mamba-org/setup-micromamba@v1 - with: - micromamba-version: '1.5.9-1' - environment-name: ab - create-args: >- - python=${{ matrix.python-version }} - activity-browser - - name: Environment info - run: | - micromamba list - micromamba env export - micromamba env export > env.yaml - - name: Upload final environment as artifact - uses: actions/upload-artifact@v4 - with: - name: env-${{ matrix.os }}-${{ matrix.python-version }}-mamba - path: env.yaml - - conda-micromamba-comparison: - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - needs: - - canary-installs - - canary-installs-mamba - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - - name: show files - run: | - ls -la - - name: correct yaml formatting - # add correct indentation to make diffing possible - uses: mikefarah/yq@master - with: - cmd: | - ls | grep mamba | while read d; do yq -i $d/env.yaml; done - - name: diff ubuntu - run: | - diff -u env-ubuntu-latest-3.11* || : - - name: diff windows - run: | - diff -u env-windows-latest-3.11* || : - - name: diff macos - run: | - diff -u env-macos-latest-3.11* || : - canary-installs-pip: timeout-minutes: 12 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ ubuntu-latest, windows-latest, macos-13 ] - python-version: [ '3.10' ] + os: [ubuntu-latest, windows-latest, macos-15, macos-latest] + py-version: ["3.10", "3.11", "3.12"] defaults: run: shell: bash -e {0} diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index ec25e782e..a0d5bee6e 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-13, macos-latest] + os: [ubuntu-latest, windows-latest, macos-15, macos-latest] py-version: ["3.10", "3.11", "3.12"] env: QT_QPA_PLATFORM: 'offscreen' @@ -42,4 +42,4 @@ jobs: - name: Test with pytest run: | - pytest + pytest -s --no-header --no-summary -q diff --git a/activity_browser/README.md b/activity_browser/README.md new file mode 100644 index 000000000..6339d5cd5 --- /dev/null +++ b/activity_browser/README.md @@ -0,0 +1,35 @@ +# activity_browser + +This is the main package directory for the Activity Browser application. + +## Overview + +Activity Browser is a Qt-based desktop application that provides a GUI front-end for Brightway2, enabling users to perform Life Cycle Assessment (LCA) calculations with an intuitive interface. + +## Directory Structure + +- **`app/`** - Main application logic, including the main window, actions, dialogs, pages, and panes +- **`bwutils/`** - Utility functions and helpers that extend Brightway2 functionality +- **`mod/`** - Monkey-patches and modifications to third-party libraries (bw2analyzer, bw2io, etc.) +- **`static/`** - Static resources including HTML templates, CSS, icons, fonts, and JavaScript files +- **`ui/`** - Core UI components including widgets, dialogs, wizards, and web views + +## Key Files + +- **`__init__.py`** - Package initialization with PySide6/typing compatibility patches +- **`__main__.py`** - Entry point for the application (`run_activity_browser` function) +- **`info.py`** - Version and application metadata + +## Entry Points + +The application can be started in multiple ways: +- Console script: `activity-browser` (installed via setuptools) +- Direct module execution: `python -m activity_browser` +- Script execution: `python run-activity-browser.py` + +All entry points lead to `activity_browser.__main__:run_activity_browser`. + +## Development Notes + +- See `CONTRIBUTING.md` for guidelines on contributing to the project +- Check out the Development notes specific to each submodule for more details on implementation diff --git a/activity_browser/__init__.py b/activity_browser/__init__.py index 1e67a74f5..e04ee0533 100644 --- a/activity_browser/__init__.py +++ b/activity_browser/__init__.py @@ -14,8 +14,26 @@ except ImportError: import qtpy -from .ui.application import application -from .signals import signals +def setup_logging(): + """Configure loguru sinks for console and file logging.""" + from loguru import logger + import os + import platformdirs + + logger.level("SYNC", no=9, color="") + logger.level("SIGNAL", no=19, color="") + logger.level("TEST", no=19, color="") + + logger.remove() + logger.add(sys.stderr, level=6, colorize=True, + format="{time:HH:mm:ss} | {level: <8} | {message}") + + log_dir = platformdirs.user_log_dir(appname="ActivityBrowser", appauthor="pylca") + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, "activity_browser.log") + logger.add(log_file, level="DEBUG", rotation="5 MB", retention=5) def run_activity_browser(): from .__main__ import run_activity_browser + +setup_logging() \ No newline at end of file diff --git a/activity_browser/__main__.py b/activity_browser/__main__.py index 418718791..0e7073f61 100644 --- a/activity_browser/__main__.py +++ b/activity_browser/__main__.py @@ -1,6 +1,11 @@ +# Divert the program flow in worker sub-process as soon as possible, +# before importing heavy-weight modules. +if __name__ == '__main__': + import multiprocessing + multiprocessing.freeze_support() + import sys import os -from logging import getLogger from importlib import metadata import requests @@ -13,13 +18,11 @@ import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("activity.browser.1") -from activity_browser import application -from activity_browser.ui import icons - -from .logger import setup_ab_logging +from loguru import logger +import platformdirs from .static.icons import main -log = getLogger(__name__) + class SpecialProgressBar(QtWidgets.QWidget): @@ -88,29 +91,14 @@ def load_modules(self): thread.start() def load_layout(self): - from .ui.widgets import MainWindow, CentralTabWidget - from .layouts import panes, pages - from activity_browser.bwutils import AB_metadata - from activity_browser import signals + self.load_finished() - application.main_window = MainWindow() - central_widget = CentralTabWidget(application.main_window) - central_widget.addTab(pages.WelcomePage(), "Welcome") - central_widget.addTab(pages.ParametersPage(), "Parameters") - - application.main_window.setCentralWidget(central_widget) + def load_finished(self): + from activity_browser import app - self.load_settings() + load_plugins() - def load_settings(self): - self.text_label.setText("Loading project") - thread = SettingsThread(self) - thread.finished.connect(self.load_finished) - thread.start() - - def load_finished(self): - application.main_window.sync() - application.main_window.show() + app.main_window.show() self.deleteLater() @@ -119,70 +107,36 @@ class ModuleThread(QtCore.QThread): def run(self): self.status.emit("Loading Numpy") - log.debug("ABLoader: Importing numpy") + logger.debug("ABLoader: Importing numpy") import numpy, pandas self.status.emit("Loading Brightway25") - log.debug("ABLoader: Importing brightway modules") + logger.debug("ABLoader: Importing brightway modules") import bw2data, bw2calc, bw2analyzer, bw2io, bw_functional, bw_processing, matrix_utils - self.status.emit("Loading Activity Browser") - log.debug("ABLoader: Importing activity_browser") - from activity_browser import actions, layouts, mod, settings, ui, signals - from activity_browser.layouts import panes, pages - from activity_browser.ui import core, widgets, web, wizards - - -class SettingsThread(QtCore.QThread): - def run(self): - import bw2data as bd - from activity_browser import settings, actions - - if settings.ab_settings.settings: - from pathlib import Path - - base_dir = Path(settings.ab_settings.current_bw_dir) - project_name = settings.ab_settings.startup_project - bd.projects.change_base_directories(base_dir, project_name=project_name, update=False) - - if not bd.projects.twofive: - log.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") - actions.ProjectSwitch.set_warning_bar() - - log.info(f"Brightway2 data directory: {bd.projects._base_data_dir}") - log.info(f"Brightway2 current project: {bd.projects.current}") def run_activity_browser(): + from activity_browser.ui.core.application import ABApplication + app = ABApplication() + pre_flight_checks() - setup_ab_logging() loader = ABLoader() loader.show() - application.set_icon() # setting this here seems to fix the icon not showing sometimes - sys.exit(application.exec_()) + + app.set_icon() # setting this here seems to fix the icon not showing sometimes + sys.exit(app.exec_()) def run_activity_browser_no_launcher(): pre_flight_checks() - setup_ab_logging() modules = ModuleThread() modules.run() - from .ui.widgets import MainWindow, CentralTabWidget - from .layouts import panes, pages - from activity_browser.bwutils import AB_metadata - from activity_browser import signals - - application.main_window = MainWindow() - central_widget = CentralTabWidget(application.main_window) - central_widget.addTab(pages.WelcomePage(), "Welcome") - central_widget.addTab(pages.ParametersPage(), "Parameters") + from .ui.widgets import CentralTabWidget + from .app import panes, pages, application, metadata - application.main_window.setCentralWidget(central_widget) + load_plugins() - settings = SettingsThread() - settings.run() - - application.main_window.sync() application.main_window.show() application.set_icon() # setting this here seems to fix the icon not showing sometimes @@ -248,11 +202,22 @@ def check_pypi_update(): "pip install --upgrade activity-browser\n\n" "Press any key to continue without updating...\033[0m") +def load_plugins(): + from activity_browser.bwutils.settings import Settings + settings = Settings() + plugins = settings["plugins"].get("enabled_plugins", []) + for plugin in plugins: + try: + __import__(plugin) + logger.info(f"Successfully loaded plugin: {plugin}") + except ImportError: + logger.warning(f"Could not load plugin: {plugin}") + if "--no-launcher" in sys.argv: run_activity_browser_no_launcher() elif sys.version_info[1] == 10: - log.info("Running Activity Browser without launcher for Python 3.10") + logger.info("Running Activity Browser without launcher for Python 3.10") run_activity_browser_no_launcher() else: run_activity_browser() diff --git a/activity_browser/app/README.md b/activity_browser/app/README.md new file mode 100644 index 000000000..7e24d9129 --- /dev/null +++ b/activity_browser/app/README.md @@ -0,0 +1,67 @@ +# app + +Main application module containing the core logic and structure of the Activity Browser. + +## Overview + +This module orchestrates the main application components including the main window, menu bar, signal handling, and various UI elements organized into actions, dialogs, pages, and panes. + +## Directory Structure + +- **`actions/`** - Encapsulated UI operations and commands (activity, database, calculation setup, etc.) +- **`dialogs/`** - Dialog windows for user interactions +- **`pages/`** - Main content pages displayed in the application (activity details, calculations, parameters, etc.) +- **`panes/`** - Dock-able panes that can be arranged around the main content area + +## Key Files + +- **`__init__.py`** - Module initialization creating singleton instances: + - `application` - ABApplication instance + - `metadata` - MetaDataStore instance + - `settings` - Settings instance + - `signals` - ABSignals instance (event bus) + - `main_window` - MainWindow instance + +- **`main_window.py`** - MainWindow class that holds the central widget and dock panes +- **`menu_bar.py`** - Application menu bar with File, Edit, View, Tools, Help menus +- **`signalling.py`** - ABSignals class that bridges bw2data signals to Qt signals + +## Architecture + +The app module creates and wires together the core application components: + +1. **Application** (`ABApplication`) - Qt application instance with global shortcut management +2. **Signals** (`ABSignals`) - Project-wide event bus for model to UI communication +3. **Main Window** (`MainWindow`) - Main application window with pages and panes +4. **Actions** - Command pattern implementation for menu items and toolbar actions. Modifying Brightway2 happens here. +5. **Pages** - Content area widgets for different application views +6. **Panes** - Dock-able side panels + +## Signal Flow + +The signals instance serves as the central event bus: +- Bridges Brightway2 data events to Qt signals +- Enables loose coupling between UI components +- Used throughout the application for state updates + +## Usage Pattern + +Components should access the application objects via: + +```python +from activity_browser import app + +# Access global instances +app.application # ABApplication instance +app.signals # Event bus +app.settings # Settings manager +app.metadata # Metadata store +app.main_window # Main window +``` + +## Development Notes + +- See `CONTRIBUTING.md` for guidelines on contributing to the project +- This module is the place to add components that depend on the application having been initialized (e.g., actions, panes) + - If the logic you want to add can only depend on brightway2, consider placing it in the `bwutils` submodule instead + - If the widget you want to add does not depend on the application, consider placing it in the `ui` submodule instead diff --git a/activity_browser/app/__init__.py b/activity_browser/app/__init__.py new file mode 100644 index 000000000..275cdb6b2 --- /dev/null +++ b/activity_browser/app/__init__.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +__all__ = ["panes", "pages", "application", "signals", "metadata", "main_window", "actions"] + +import os + +from activity_browser.ui.core.application import ABApplication +from activity_browser.bwutils.metadata import MetaDataStore +from activity_browser.bwutils.settings import Settings +from .main import MainWindow + +application = ABApplication() +metadata = MetaDataStore(application) +settings = Settings() + +# modules dependent on application instance +from .signalling import ABSignals + +signals = ABSignals() + +# modules dependent on application and signals +from . import actions +from . import panes +from . import pages +from . import dialogs + +main_window = MainWindow() +application.main_window = main_window + +if not os.environ.get("AB_SKIP_SETTINGS_ON_STARTUP"): + main_window.apply_settings(load=True) # Ensure settings are applied at startup + diff --git a/activity_browser/app/actions/README.md b/activity_browser/app/actions/README.md new file mode 100644 index 000000000..028d435c3 --- /dev/null +++ b/activity_browser/app/actions/README.md @@ -0,0 +1,92 @@ +# actions + +Encapsulated UI operations and commands following the action pattern. + +## Overview + +This directory contains all user-triggered actions in Activity Browser. Each action represents a discrete operation that can be invoked from menus, toolbars, or keyboard shortcuts. + +## Directory Structure + +- **`activity/`** - Actions related to activities (create, edit, delete, duplicate, etc.) +- **`calculation_setup/`** - Actions for calculation setup management +- **`database/`** - Database operations (import, export, delete, backup, etc.) +- **`exchange/`** - Actions for exchanges between activities +- **`method/`** - Impact assessment method management +- **`parameter/`** - Parameter management actions +- **`project/`** - Project-level operations +- **`tools/`** - Various tools and utilities accessible via actions + +## Action Pattern + +All actions follow a consistent pattern defined in `base.py`: + +```python +class MyAction(ABAction): + icon = QtGui.QIcon(...) # Action icon + text = "My Action" # Display text + tooltip = "Description" # Tooltip text + + @staticmethod + def run(*args, **kwargs): + # Action implementation + pass +``` + +### Key Features: + +1. **Declarative** - Icon, text, and tooltip defined as class attributes +2. **Callable arguments** - Arguments can be functions (evaluated at runtime) +3. **Qt integration** - Can be converted to QAction or QPushButton +4. **Exception handling** - Always add `@exception_dialogs` decorator for user-facing errors +5. **Flexible invocation** - Triggered from menus, buttons, shortcuts + +## Usage + +Actions can be used in multiple ways: + +### As Menu Items +```python +action = MyAction.get_QAction(parent=menu) +menu.addAction(action) +``` + +### As Buttons +```python +button = MyAction.get_QButton() +layout.addWidget(button) +``` + +### Direct Invocation +```python +MyAction.run(arg1, arg2) +``` + +## Subdirectory Organization + +Each subdirectory groups related actions: + +- **`activity/`** - Activity CRUD operations, navigation, graph viewing +- **`calculation_setup/`** - Setup creation, modification, calculation execution +- **`database/`** - Import from various sources, export, deletion, backup/restore +- **`exchange/`** - Add/remove/modify exchanges, uncertainty, formulas +- **`method/`** - Method import, export, modification, deletion +- **`parameter/`** - Parameter creation, editing, scenarios +- **`project/`** - Project creation, switching, deletion, settings +- **`tools/`** - Monte Carlo, sensitivity analysis, superstructure tools + +## Development Guidelines + +When adding new actions: + +1. Inherit from `ABAction` base class +2. Define icon, text, and tooltip class attributes +3. Implement the `run()` static method with the action logic +4. Place in the appropriate subdirectory by functionality +5. Use `@exception_dialogs` decorator for user-facing error handling +6. Import and register in the parent `__init__.py` +7. Connect to global signals when state changes + +## Signal Integration + +**Actions should not emit signals themselves** That being said, actions should only emit signals when they modify state in a way that Brightway2 does not automatically notify the UI about. See e.g. parameter_group_delete.py for an example of emitting a signal after deleting a parameter group. diff --git a/activity_browser/app/actions/__init__.py b/activity_browser/app/actions/__init__.py new file mode 100644 index 000000000..b7ccc729d --- /dev/null +++ b/activity_browser/app/actions/__init__.py @@ -0,0 +1,100 @@ +from .activity.activity_relink import ActivityRelink +from .activity.activity_duplicate import ActivityDuplicate +from .activity.activity_open import ActivityOpen +from .activity.activity_delete import ActivityDelete +from .activity.activity_duplicate_to_db import ActivityDuplicateToDB +from .activity.activity_modify import ActivityModify +from .activity.activity_new_process import ActivityNewProcess +from .activity.activity_new_product import ActivityNewProduct +from .activity.activity_open import ActivityOpen +from .activity.activity_relink import ActivityRelink +from .activity.activity_sdf_to_clipboard import ActivitySDFToClipboard +from .activity.process_property_modify import ProcessPropertyModify +from .activity.process_property_remove import ProcessPropertyRemove + +from .calculation_setup.cs_new import CSNew +from .calculation_setup.cs_delete import CSDelete +from .calculation_setup.cs_duplicate import CSDuplicate +from .calculation_setup.cs_rename import CSRename +from .calculation_setup.cs_add_functional_unit import CSAddFunctionalUnit +from .calculation_setup.cs_change_functional_unit import CSChangeFunctionalUnit +from .calculation_setup.cs_add_impact_category import CSAddImpactCategory +from .calculation_setup.cs_delete_impact_category import CSDeleteImpactCategory +from .calculation_setup.cs_delete_functional_unit import CSDeleteFunctionalUnit +from .calculation_setup.cs_calculate import CSCalculate +from .calculation_setup.cs_open import CSOpen + +from .database.database_open import DatabaseOpen +from .database.database_export_excel import DatabaseExportExcel +from .database.database_export_bw2package import DatabaseExportBW2Package +from .database.database_new import DatabaseNew +from .database.database_delete import DatabaseDelete +from .database.database_duplicate import DatabaseDuplicate +from .database.database_relink import DatabaseRelink +from .database.database_explorer_open import DatabaseExplorerOpen +from .database.database_process import DatabaseProcess +from .database.database_import_from_ecoinvent import DatabaseImportFromEcoinvent +from .database.database_importer_excel import DatabaseImporterExcel +from .database.database_importer_bw2package import DatabaseImporterBW2Package +from .database.database_set_readonly import DatabaseSetReadonly + +from .exchange.exchange_copy_sdf import ExchangeCopySDF + +from .exchange.exchange_new import ExchangeNew +from .exchange.exchange_delete import ExchangeDelete +from .exchange.exchange_modify import ExchangeModify +from .exchange.exchange_formula_remove import ExchangeFormulaRemove +from .exchange.exchange_uncertainty_modify import ExchangeUncertaintyModify +from .exchange.exchange_uncertainty_remove import ExchangeUncertaintyRemove +from .exchange.exchange_copy_sdf import ExchangeCopySDF +from .exchange.exchange_sdf_to_clipboard import ExchangeSDFToClipboard + +from .method.method_duplicate import MethodDuplicate +from .method.method_delete import MethodDelete +from .method.method_open import MethodOpen +from .method.method_rename import MethodRename +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.cf_uncertainty_modify import CFUncertaintyModify +from .method.cf_amount_modify import CFAmountModify +from .method.cf_remove import CFRemove +from .method.cf_new import CFNew +from .method.cf_uncertainty_remove import CFUncertaintyRemove + +from .parameter.parameter_new import ParameterNew +from .parameter.parameter_new_automatic import ParameterNewAutomatic +from .parameter.parameter_new_from_parameter import ParameterNewFromParameter +from .parameter.parameter_rename import ParameterRename +from .parameter.parameter_delete import ParameterDelete +from .parameter.parameter_modify import ParameterModify +from .parameter.parameter_uncertainty_remove import ParameterUncertaintyRemove +from .parameter.parameter_uncertainty_modify import ParameterUncertaintyModify +from .parameter.parameter_clear_broken import ParameterClearBroken +from .parameter.parameter_group_delete import ParameterGroupDelete + +from .project.project_new import ProjectNew +from .project.project_duplicate import ProjectDuplicate +from .project.project_delete import ProjectDelete +from .project.project_duplicate import ProjectDuplicate +from .project.project_remote_import import ProjectRemoteImport +from .project.project_local_import import ProjectLocalImport +from .project.project_new import ProjectNew +from .project.project_new_remote import ProjectNewRemote +from .project.project_switch import ProjectSwitch +from .project.project_export import ProjectExport +from .project.project_import import ProjectImport +from .project.project_manager_open import ProjectManagerOpen +from .project.project_migrate25 import ProjectMigrate25 +from .project.project_create_template import ProjectCreateTemplate +from .project.project_new_template import ProjectNewFromTemplate + +from .migrations_install import MigrationsInstall +from .pyside_upgrade import PysideUpgrade +from .metadatastore_open import MetaDataStoreOpen +from .node_select_open import NodeSelectOpen +from .save_parameters_to_excel import SaveParametersToExcel +from .metadatastore_cache_clear import MetaDataStoreCacheClear diff --git a/activity_browser/app/actions/activity/activity_delete.py b/activity_browser/app/actions/activity/activity_delete.py new file mode 100644 index 000000000..213e1186a --- /dev/null +++ b/activity_browser/app/actions/activity/activity_delete.py @@ -0,0 +1,97 @@ +from typing import List + +from qtpy import QtWidgets + +import bw2data as bd +import bw_functional as bf + +from bw2data.parameters import (ActivityParameter, Group, + GroupDependency, + parameters) + +from activity_browser.app import application +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + + +class ActivityDelete(ABAction): + """ + ABAction to delete one or multiple activities if supplied by activity keys. Will check if an activity has any + downstream processes and ask the user whether they want to continue if so. Exchanges from any downstream processes + will be removed + """ + + icon = qicons.delete + text = "Delete ***" + + @staticmethod + @exception_dialogs + def run(activity_keys: List[tuple]): + # retrieve activity objects from the controller using the provided keys + activities = [bd.get_activity(key) for key in activity_keys] + + warnings = [] + if len(activities) == 1: + warnings.append(f"Are you certain you want to delete {activities[0]['name']}?") + else: + warnings.append(f"Are you certain you want to delete {len(activities)} nodes?") + + warnings.append("") # add a blank line for readability + + if any(len(act.upstream()) > 0 for act in activities): + warnings.append("One or more of the activities you are trying to delete have consumers") + + if any([act for act in activities if isinstance(act, bf.Process)]): + warnings.append("Products of processes will be removed as well") + + warning_text = "
".join(warnings) + + # alert the user + choice = QtWidgets.QMessageBox.warning( + application.main_window, + build_title(activities), + warning_text, + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + + # return if the user cancels + if choice == QtWidgets.QMessageBox.No: + return + + for act in activities: + db, code = act.key + + try: + group_name = ActivityParameter.get( + (ActivityParameter.database == db) + & (ActivityParameter.code == code) + ).group + + # remove activity parameters from its group + parameters.remove_from_group(group_name, act) + + # Also clear the group if there are no more parameters in it + if ( + not ActivityParameter.select() + .where(ActivityParameter.group == group_name) + .exists() + ): + Group.get(Group.name == group_name).delete_instance() + GroupDependency.delete().where( + GroupDependency.group == group_name + ).execute() + except ActivityParameter.DoesNotExist: + # no parameters found for this activity + pass + + # Included in bw2data as of 4.1 + # act.upstream().delete() + + act.delete() + + +def build_title(activities: List[bd.Node]) -> str: + if len(activities) == 1: + return "Delete node" + return "Delete nodes" diff --git a/activity_browser/app/actions/activity/activity_duplicate.py b/activity_browser/app/actions/activity/activity_duplicate.py new file mode 100644 index 000000000..1197a994d --- /dev/null +++ b/activity_browser/app/actions/activity/activity_duplicate.py @@ -0,0 +1,26 @@ +from typing import Callable, List, Union + +from qtpy import QtCore + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils import commontasks +from bw2data import get_activity +from activity_browser.ui.icons import qicons + + +class ActivityDuplicate(ABAction): + """ + Duplicate one or multiple activities using their keys. Proxy action to call the controller. + """ + + icon = qicons.copy + text = "Duplicate ***" + + @staticmethod + @exception_dialogs + def run(activity_keys: List[tuple]): + activities = [get_activity(key) for key in activity_keys] + + for activity in activities: + new_code = commontasks.generate_copy_code(activity.key) + activity.copy(new_code) diff --git a/activity_browser/app/actions/activity/activity_duplicate_to_db.py b/activity_browser/app/actions/activity/activity_duplicate_to_db.py new file mode 100644 index 000000000..d677590a1 --- /dev/null +++ b/activity_browser/app/actions/activity/activity_duplicate_to_db.py @@ -0,0 +1,158 @@ +from typing import List + +from qtpy import QtWidgets + +import bw2data as bd +import bw_functional as bf + +from activity_browser.app import application +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons +from bw_functional import Product + +from .activity_open import ActivityOpen + + +class ActivityDuplicateToDB(ABAction): + icon = qicons.duplicate_to_other_database + text = "Duplicate to other database" + + @classmethod + @exception_dialogs + def run(cls, nodes: List[tuple | int | bd.Node], to_db_name: str = None): + nodes = [refresh_node(node) for node in nodes] + dbs = {node.get("database") for node in nodes} + from_db_name = next(iter(dbs)) + from_db_backend = bd.databases[from_db_name]["backend"] + + if not len(dbs) == 1: + raise ValueError("All selected activities must be from the same database.") + + if any([isinstance(node, bf.Product) for node in nodes]): + raise ValueError("Products cannot be duplicated to another database. Duplicate the parent process instead.") + + if to_db_name: + if not cls.confirm_db(to_db_name): + return + else: + to_db_name = cls.request_db(from_db_name) + + to_db_backend = bd.databases[to_db_name]["backend"] + + if from_db_backend == to_db_backend: + new_nodes = cls.duplicate_simple(nodes, to_db_name) + elif from_db_backend == "sqlite" and to_db_backend == "functional_sqlite": + new_nodes = cls.duplicate_sqlite_to_functional_sqlite(nodes, to_db_name) + elif from_db_backend == "functional_sqlite" and to_db_backend == "sqlite": + new_nodes = cls.duplicate_functional_sqlite_to_sqlite(nodes, to_db_name) + else: + raise NotImplementedError(f"Copying from {from_db_backend} to {to_db_backend} is not supported.") + + ActivityOpen.run(new_nodes) + + @staticmethod + def request_db(from_db_name: str) -> str | None: + # get valid databases (not the original database, or locked databases) + target_dbs = [ + db_name for db_name, meta in bd.databases.items() if + db_name != from_db_name + and meta.get("read_only", True) is not True + ] + + # return if there are no valid databases to duplicate to + if not target_dbs: + QtWidgets.QMessageBox.warning( + application.main_window, + "No target database", + "No valid target databases available. Create a new database or set one to writable (not read-only).", + ) + return + + # construct a dialog where the user can choose a database to duplicate to + target_db, ok = QtWidgets.QInputDialog.getItem( + application.main_window, + "Move node to database", + "Target database:", + target_dbs, + 0, + False, + ) + + return target_db if ok else None + + @staticmethod + def confirm_db(to_db_name: str): + user_choice = QtWidgets.QMessageBox.question( + application.main_window, + "Move to new database", + f"Move to {to_db_name} and open as new tab?", + ) + return user_choice == user_choice.Yes + + @staticmethod + def duplicate_simple(nodes: list[bd.Node], to_db_name: str) -> list[bd.Node]: + new_nodes = [] + + # move all supplied nodes to the db by copying them + for node in nodes: + new_node = node.copy(database=to_db_name) + new_nodes.append(new_node) + + return new_nodes + + @staticmethod + def duplicate_sqlite_to_functional_sqlite(nodes: list[bd.Node], to_db_name: str) -> list[bd.Node]: + from bw_functional.convert import SQLiteToFunctionalSQLite + new_nodes = [] + + for node in nodes: + dataset = node.as_dict() + + dataset.pop("id", None) + dataset.pop("key", None) + + dataset["exchanges"] = [exc.as_dict() for exc in node.exchanges()] + dataset["database"] = to_db_name # because we didn't copy the dict this will also be reflected in node.key + + new_datasets = SQLiteToFunctionalSQLite.convert_process(node.key, dataset, False) + new_exchanges = [x for ds in new_datasets.values() for x in ds.pop("exchanges", [])] + + for key, new_dataset in new_datasets.items(): + new_node = bd.Node(**new_dataset) + new_node.save() + new_nodes.append(new_node) + + for exc in new_exchanges: + exc["output"] = (to_db_name, exc["output"][1]) # relink output to new db + new_exc = bd.Edge(**exc) + new_exc.save() + + return new_nodes + + @staticmethod + def duplicate_functional_sqlite_to_sqlite(nodes: list[bd.Node], to_db_name: str) -> list[bd.Node]: + new_nodes = [] + products = [prod for node in nodes if isinstance(node, bf.Process) for prod in node.products()] + + for product in products: + dataset = product.as_dict() + dataset.pop("id", None) + dataset.pop("key", None) + + exchanges = product.virtual_edges + dataset["database"] = to_db_name + dataset["type"] = "processwithreferenceproduct" + + new_node = bd.Node(**dataset) + new_node.save() + new_nodes.append(new_node) + + for exc in exchanges: + exc["output"] = (to_db_name, exc["output"][1]) + if exc["type"] == "production": + exc["input"] = (to_db_name, new_node.key[1]) + new_exc = bd.Edge(**exc) + new_exc.save() + + return new_nodes diff --git a/activity_browser/app/actions/activity/activity_modify.py b/activity_browser/app/actions/activity/activity_modify.py new file mode 100644 index 000000000..4eb54fa98 --- /dev/null +++ b/activity_browser/app/actions/activity/activity_modify.py @@ -0,0 +1,27 @@ +from activity_browser.app.actions.base import ABAction, exception_dialogs +from bw2data import get_node, Node +from activity_browser.ui.icons import qicons +from activity_browser.bwutils.commontasks import refresh_node + + +class ActivityModify(ABAction): + """ + ABAction to delete one or multiple activities if supplied by activity keys. Will check if an activity has any + downstream processes and ask the user whether they want to continue if so. Exchanges from any downstream processes + will be removed + """ + + icon = qicons.edit + text = "Modify Activity" + + @staticmethod + @exception_dialogs + def run(activity: tuple | int | Node, field: str, value: any): + activity = refresh_node(activity) + + if field == "product": + # for some reason product needs to be set like this + field = "reference product" + + activity[field] = value + activity.save() diff --git a/activity_browser/app/actions/activity/activity_new_process.py b/activity_browser/app/actions/activity/activity_new_process.py new file mode 100644 index 000000000..79725152c --- /dev/null +++ b/activity_browser/app/actions/activity/activity_new_process.py @@ -0,0 +1,130 @@ +from uuid import uuid4 + +from qtpy import QtWidgets +import bw2data as bd + +from activity_browser import app +from activity_browser.bwutils.commontasks import database_is_legacy +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + +from .activity_open import ActivityOpen + + +class ActivityNewProcess(ABAction): + """ + ABAction to create a new activity. Prompts the user to supply a name. Returns if no name is supplied or if the user + cancels. Otherwise, instructs the ActivityController to create a new activity. + """ + + icon = qicons.add + text = "New process" + + @staticmethod + @exception_dialogs + def run(database_name: str): + # ask the user to provide a name for the new activity + dialog = NewNodeDialog(app.main_window) + # if the user cancels, return + if dialog.exec_() != QtWidgets.QDialog.DialogCode.Accepted: + return + name, ref_product, unit, location = dialog.get_new_process_data() + # if no name is provided, return + if not name: + return + if ref_product == "": + ref_product = name + + database = bd.Database(database_name) + legacy_backend = database_is_legacy(database_name) + + # create process + new_proc_data = { + "name": name, + "location": location, + "type": "process" if not legacy_backend else "processwithreferenceproduct", + } + + if legacy_backend: + new_proc_data["reference product"] = ref_product + new_proc_data["unit"] = unit + + new_process: bd.Node = database.new_activity(code=uuid4().hex, **new_proc_data) + new_process.save() + + if legacy_backend: + new_process.new_exchange( + input=new_process.key, + type="production", + amount=1.0, + ).save() + + if not legacy_backend: + # create reference product + new_ref_prod_data = { + "product": ref_product, + "unit": unit, + "location": location, + "type": "product", + } + prod = new_process.new_product(code=uuid4().hex, **new_ref_prod_data) + prod.save() + + ActivityOpen.run([new_process.key]) + + +class NewNodeDialog(QtWidgets.QDialog): + """ + Gathers the paremeters for creating a new process. + """ + + def __init__(self, process: bool = True, parent = None): + super().__init__(parent) + layout = QtWidgets.QGridLayout() + row = 0 + if process: + self.setWindowTitle("New process") + layout.addWidget(QtWidgets.QLabel("Process name"), row, 0) + else: + self.setWindowTitle("New product") + layout.addWidget(QtWidgets.QLabel("Product name"), row, 0) + self._process_name_edit = QtWidgets.QLineEdit() + self._process_name_edit.textChanged.connect(self._handle_text_changed) + layout.addWidget(self._process_name_edit, row, 1) + row += 1 + self._ref_product_name_edit = QtWidgets.QLineEdit() + if process: + layout.addWidget(QtWidgets.QLabel("Product name"), row, 0) + layout.addWidget(self._ref_product_name_edit, row, 1) + row += 1 + layout.addWidget(QtWidgets.QLabel("Unit"), row, 0) + self._unit_edit = QtWidgets.QLineEdit("kilogram") + layout.addWidget(self._unit_edit, row, 1) + row += 1 + layout.addWidget(QtWidgets.QLabel("Location"), row, 0) + default_loc = "GLO" if process else "" + self._location_edit = QtWidgets.QLineEdit(default_loc) + layout.addWidget(self._location_edit, row, 1) + row += 1 + self._ok_button = QtWidgets.QPushButton("OK") + self._ok_button.clicked.connect(self.accept) + self._ok_button.setEnabled(False) + layout.addWidget(self._ok_button, row, 0) + cancel_button = QtWidgets.QPushButton("Cancel") + cancel_button.clicked.connect(self.reject) + layout.addWidget(cancel_button, row, 1) + self.setLayout(layout) + + def _handle_text_changed(self, text: str): + self._ok_button.setEnabled(text != "") + self._ref_product_name_edit.setPlaceholderText(text) + + def get_new_process_data(self) -> tuple[str, str, str, str]: + """Return the parameters the user entered.""" + return ( + self._process_name_edit.text(), + self._ref_product_name_edit.text(), + self._unit_edit.text(), + self._location_edit.text() + ) + diff --git a/activity_browser/app/actions/activity/activity_new_product.py b/activity_browser/app/actions/activity/activity_new_product.py new file mode 100644 index 000000000..305ebb39d --- /dev/null +++ b/activity_browser/app/actions/activity/activity_new_product.py @@ -0,0 +1,154 @@ +from uuid import uuid4 + +from qtpy import QtWidgets + +import bw2data as bd + +from bw_functional import Process + +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + + +class ActivityNewProduct(ABAction): + """ + ABAction to create a new product for an activity. + + This action prompts the user to supply a name, unit, and location for the new product. + If the user cancels or does not provide a name, the action is aborted. + Otherwise, it creates a new product associated with the given activity. + + Attributes: + icon (QIcon): The icon representing this action. + text (str): The display text for this action. + """ + + icon = qicons.add + text = "Create product" + + @staticmethod + @exception_dialogs + def run(activities: list[tuple | int | bd.Node], product_type: str = "product"): + """ + Execute the action to create a new product. + + This method iterates over the provided activities, ensuring each is a `Process`. + It prompts the user to input details for the new product. If valid details are provided, + a new product is created and saved. + + Args: + activities (list[tuple | int | bd.Node]): A list of activities to process. + product_type (str, optional): The type of the new product. Defaults to "product". + + Raises: + AssertionError: If an activity is not of type `Process`. + """ + activities = [refresh_node(activity) for activity in activities] + + for act in activities: + assert isinstance(act, Process), "Cannot create new product for non-process type" + # Ask the user to provide a name for the new product + dialog = NewProductDialog(act, product_type, app.main_window) + # If the user cancels, skip to the next activity + if dialog.exec_() != QtWidgets.QDialog.Accepted: + continue + name, unit, location = dialog.get_new_process_data() + # If no name is provided, skip to the next activity + if not name: + continue + + # Create the new product + new_prod_data = { + "product": name, + "unit": unit, + "location": location, + "type": product_type, + } + new_product = act.new_product(code=uuid4().hex, **new_prod_data) + new_product.save() + + +class NewProductDialog(QtWidgets.QDialog): + """ + A dialog for gathering parameters to create a new product. + + This dialog allows the user to input the product name, unit, and location. + It validates the input and provides options to either create the product or cancel the operation. + """ + + def __init__(self, activity: bd.Node, product_type: str, parent: QtWidgets.QWidget = None): + """ + Initialize the NewProductDialog. + + Args: + activity (bd.Node): The activity for which the product is being created. + Used to prefill the location field and set the dialog title. + product_type (str): The type of the new product ("product", "waste"). + parent (QtWidgets.QWidget, optional): The parent widget for the dialog. Defaults to None. + """ + super().__init__(parent) + + # Set the dialog window title + self.setWindowTitle(f"Create {product_type} for {activity['name']}") + + # Input fields for product details + self.name_edit = QtWidgets.QLineEdit() + self.unit_edit = QtWidgets.QLineEdit("kilogram") # Default unit is "kilogram" + self.location_edit = QtWidgets.QLineEdit(activity.get("location", "")) # Prefill location if available + + # Buttons for user actions + self.ok_button = QtWidgets.QPushButton("Create") + self.ok_button.clicked.connect(self.accept) # Connect the "Create" button to accept the dialog + self.ok_button.setEnabled(False) # Initially disable the button until a name is entered + + self.cancel_button = QtWidgets.QPushButton("Cancel") + self.cancel_button.clicked.connect(self.reject) # Connect the "Cancel" button to reject the dialog + + # Set up signals and layout + self.connect_signals() + self.build_layout() + + def connect_signals(self): + """ + Connect signals to their respective handlers. + + - Enables the "Create" button only when the name field is not empty. + """ + self.name_edit.textChanged.connect(lambda x: self.ok_button.setEnabled(bool(x))) + + def build_layout(self): + """ + Build and set the layout for the dialog. + + The layout includes labels and input fields for product name, unit, and location, + as well as "Create" and "Cancel" buttons. + """ + layout = QtWidgets.QGridLayout() + layout.addWidget(QtWidgets.QLabel("Name"), 0, 0) + layout.addWidget(self.name_edit, 0, 1) + + layout.addWidget(QtWidgets.QLabel("Unit"), 1, 0) + layout.addWidget(self.unit_edit, 1, 1) + + layout.addWidget(QtWidgets.QLabel("Location"), 2, 0) + layout.addWidget(self.location_edit, 2, 1) + + layout.addWidget(self.ok_button, 3, 0) + layout.addWidget(self.cancel_button, 3, 1) + + self.setLayout(layout) + + def get_new_process_data(self) -> tuple[str, str, str]: + """ + Retrieve the parameters entered by the user. + + Returns: + tuple[str, str, str]: A tuple containing the product name, unit, and location. + """ + return ( + self.name_edit.text(), + self.unit_edit.text(), + self.location_edit.text() + ) diff --git a/activity_browser/app/actions/activity/activity_open.py b/activity_browser/app/actions/activity/activity_open.py new file mode 100644 index 000000000..fe270d1dd --- /dev/null +++ b/activity_browser/app/actions/activity/activity_open.py @@ -0,0 +1,63 @@ +from loguru import logger + +import bw2data as bd +import bw_functional as bf + +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node, is_node_process +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + + + + +class ActivityOpen(ABAction): + """ + ABAction to open one or more activities. + + This action processes a list of activities, validates their types, and opens + their details in the application's central widget. Unsupported activity types + are logged as warnings. + + Attributes: + icon (QIcon): The icon representing this action. + text (str): The display text for this action. + """ + + icon = qicons.right + text = "Open activity / activities" + + @staticmethod + @exception_dialogs + def run(activities: list[tuple | int | bd.Node]): + """ + Execute the action to open activities. + + This method refreshes the provided activities, validates their types, and + opens their details in the "Activity Details" group of the central widget. + + Args: + activities (list[tuple | int | bd.Node]): A list of activities to process. + + Logs: + Warning: If an activity type is not supported. + """ + from activity_browser.app import pages + + # Refresh the activity nodes to ensure they are up-to-date + activities = [refresh_node(activity) for activity in activities] + processes = [refresh_node(function["processor"]) for function in activities if isinstance(function, bf.Product)] + activities = list(set(activities + processes)) + + for act in activities: + # Check if the activity type is supported + if not is_node_process(act): + logger.warning(f"Can't open activity {act.key} - opening type: `{act.get('type')}` not supported") + continue + + # Create a details page for the activity + page = pages.ActivityDetailsPage(act) + central = app.main_window.centralWidget() + + # Add the details page to the "Activity Details" group in the central widget + central.addToGroup("Activity Details", page) diff --git a/activity_browser/app/actions/activity/activity_relink.py b/activity_browser/app/actions/activity/activity_relink.py new file mode 100644 index 000000000..69038ff09 --- /dev/null +++ b/activity_browser/app/actions/activity/activity_relink.py @@ -0,0 +1,220 @@ +from typing import List + +from qtpy import QtCore, QtWidgets + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.strategies import relink_activity_exchanges +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + + +class ActivityRelink(ABAction): + """ + ABAction to relink the exchanges of an activity to exchanges from another database. + + This action only uses the first key from activity_keys + """ + + icon = qicons.edit + text = "Relink the activity exchanges" + + @staticmethod + @exception_dialogs + def run(activity_keys: List[tuple]): + # this action only uses the first key supplied to activity_keys + key = activity_keys[0] + + # extract the brightway database and activity + db = bd.Database(key[0]) + activity = bd.get_activity(key) + + # find the dependents for the database and construct the alternatives in tuple format + depends = db.find_dependents() + options = [(depend, list(bd.databases)) for depend in depends] + + # present the alternatives to the user in a linking dialog + dialog = ActivityLinkingDialog.relink_sqlite( + activity["name"], options, app.main_window + ) + + # return if the user cancels + if dialog.exec_() == ActivityLinkingDialog.Rejected: + return + + # relinking will take some time, set WaitCursor + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + + # use the relink_activity_exchanges strategy to relink the exchanges of the activity + relinking_results = {} + for old, new in dialog.relink.items(): + other = bd.Database(new) + failed, succeeded, examples = relink_activity_exchanges( + activity, old, other + ) + relinking_results[f"{old} --> {other.name}"] = (failed, succeeded) + + # restore normal cursor + QtWidgets.QApplication.restoreOverrideCursor() + + # if any relinks failed present them to the user + if failed > 0: + relinking_dialog = ActivityLinkingResultsDialog.present_relinking_results( + app.main_window, relinking_results, examples + ) + relinking_dialog.exec_() + + +class ActivityLinkingDialog(QtWidgets.QDialog): + """ + Displays the possible databases for relinking the exchanges for a given activity + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Activity linking") + + self.db_label = QtWidgets.QLabel() + self.label_choices = [] + self.grid_box = QtWidgets.QGroupBox("Database links:") + self.grid = QtWidgets.QGridLayout() + self.grid_box.setLayout(self.grid) + + self.buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, + ) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.db_label) + layout.addWidget(self.grid_box) + layout.addWidget(self.buttons) + self.setLayout(layout) + + @property + def relink(self) -> dict: + """Returns a dictionary of str -> str key/values, showing which keys + should be linked to which values. + + Only returns key/value pairs if they differ. + """ + return { + label.text(): combo.currentText() + for label, combo in self.label_choices + if label.text() != combo.currentText() + } + + @property + def links(self) -> dict: + """Returns a dictionary of str -> str key/values, showing which keys + should be linked to which values. + """ + return { + label.text(): combo.currentText() for label, combo in self.label_choices + } + + @classmethod + def construct_dialog( + cls, + label: str, + options: list, + parent: QtWidgets.QWidget = None, + ) -> "ActivityLinkingDialog": + obj = cls(parent) + obj.db_label.setText(label) + # Start at 1 because row 0 is taken up by the db_label + for i, item in enumerate(options): + label = QtWidgets.QLabel(item[0]) + combo = QtWidgets.QComboBox() + combo.addItems(item[1]) + combo.setCurrentText(item[0]) + obj.label_choices.append((label, combo)) + obj.grid.addWidget(label, i, 0, 1, 2) + obj.grid.addWidget(combo, i, 2, 1, 2) + obj.updateGeometry() + return obj + + @classmethod + def relink_sqlite( + cls, act: str, options: list, parent=None + ) -> "ActivityLinkingDialog": + label = "Relinking exchanges from activity '{}'.".format(act) + return cls.construct_dialog(label, options, parent) + + +class ActivityLinkingResultsDialog(QtWidgets.QDialog): + """ + Provides a summary from a relinking of activity exchanges for the relinking of a + single activity. + A simple design layout based on the DatabaseLinkingResultsDialog + """ + + def __init__(self, parent=None): + super().__init__(parent) + + self.setWindowTitle("Relinking database results") + + button = QtWidgets.QDialogButtonBox.Ok + self.buttonBox = QtWidgets.QDialogButtonBox(button) + self.buttonBox.accepted.connect(self.accept) + self.databases_relinked = QtWidgets.QVBoxLayout() + + self.activityToOpen = set() + + self.exchangesUnlinked = QtWidgets.QVBoxLayout() + + self.layout = QtWidgets.QVBoxLayout() + self.layout.addLayout(self.databases_relinked) + self.layout.addLayout(self.exchangesUnlinked) + self.layout.addWidget(self.buttonBox) + self.setLayout(self.layout) + + @classmethod + def construct_results_dialog( + cls, + parent: QtWidgets.QWidget = None, + link_results: dict = None, + unlinked_exchanges: dict = None, + ) -> "ActivityLinkingResultsDialog": + from activity_browser import app + + obj = cls(parent) + for k, results in link_results.items(): + obj.databases_relinked.addWidget( + QtWidgets.QLabel(f"{k} = {results[1]} successfully linked") + ) + obj.databases_relinked.addWidget( + QtWidgets.QLabel(f"{k} = {results[0]} flows failed to link") + ) + + obj.exchangesUnlinked.addWidget( + QtWidgets.QLabel("Up to 5 unlinked exchanges (click to open)") + ) + for act, key in unlinked_exchanges.items(): + button = QtWidgets.QPushButton(act.as_dict()["name"]) + button.clicked.connect( + lambda: app.actions.ActivityOpen.run([act.key]) + ) + obj.exchangesUnlinked.addWidget(button) + obj.updateGeometry() + + return obj + + @classmethod + def present_relinking_results( + cls, + parent: QtWidgets.QWidget = None, + link_results: dict = None, + unlinked_exchanges: dict = None, + ) -> "ActivityLinkingResultsDialog": + return cls.construct_results_dialog(parent, link_results, unlinked_exchanges) + + def select_activity_to_open(self, actvty: tuple) -> None: + if actvty in self.activityToOpen: + self.activityToOpen.discard(actvty) + self.activityToOpen.add(actvty) + + def open_activity(self): + return self.activityToOpen + diff --git a/activity_browser/app/actions/activity/activity_sdf_to_clipboard.py b/activity_browser/app/actions/activity/activity_sdf_to_clipboard.py new file mode 100644 index 000000000..f74a5c690 --- /dev/null +++ b/activity_browser/app/actions/activity/activity_sdf_to_clipboard.py @@ -0,0 +1,37 @@ +from typing import List + +import bw2data as bd +import bw_functional as bf + +from activity_browser.bwutils.commontasks import refresh_node, exchanges_to_sdf +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + + +class ActivitySDFToClipboard(ABAction): + """ + ABAction to open one or more supplied activities in an activity tab by employing signals. + + TODO: move away from using signals like this. Probably add a method to the MainWindow to add a panel instead. + """ + + icon = qicons.superstructure + text = "SDF to clipboard" + + @staticmethod + @exception_dialogs + def run(activities: List[tuple | int | bd.Node]): + activities = [refresh_node(node) for node in activities] + + exchanges = [] + for activity in activities: + if isinstance(activity, bf.Product): + exchanges += activity.virtual_edges + if isinstance(activity, bf.Process): + for product in activity.products(): + exchanges += product.virtual_exchanges + else: + exchanges += [exc.as_dict() for exc in activity.exchanges()] + + df = exchanges_to_sdf(exchanges) + df.to_clipboard(excel=True, index=False) diff --git a/activity_browser/app/actions/activity/process_property_modify.py b/activity_browser/app/actions/activity/process_property_modify.py new file mode 100644 index 000000000..26e60b30c --- /dev/null +++ b/activity_browser/app/actions/activity/process_property_modify.py @@ -0,0 +1,136 @@ +from qtpy import QtWidgets, QtCore + +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + +from bw_functional import Process + + +class ProcessPropertyModify(ABAction): + """ + Modify a property for all the products of a process. + + This method refreshes the given process, validates its type, and opens a dialog + for the user to modify a property. If the property already exists, the dialog + is pre-populated with its current values. The updated property is then applied + to all products of the process. + + Args: + process (tuple | int | Process): The process to modify. Can be a tuple, integer, or Process object. + property_name (str, optional): The name of the property to modify. Defaults to None. + + Raises: + ValueError: If the provided process is not of type Process. + """ + + icon = qicons.edit + text = "Modify property" + + @staticmethod + @exception_dialogs + def run(process: tuple | int | Process, + property_name: str = None + ): + + process = refresh_node(process) + if not isinstance(process, Process): + raise ValueError(f"Expected a Process-type activity, got {type(process)} instead") + + prop_dialog = PropertyDialog(process) + + # if the property already exists, populate the dialog with the existing values + if property_name in process.available_properties(): + prop = process.property_template(property_name) + prop_dialog.prop_name.setText(property_name) + prop_dialog.prop_unit.setText(prop["unit"]) + prop_dialog.normalize_check.setChecked(prop.get("normalize", True)) + + # show the dialog to the user + if prop_dialog.exec_() != QtWidgets.QDialog.DialogCode.Accepted: + return + + name_changed = prop_dialog.name != property_name if property_name else False + + for product in process.products(): + # make sure the dictionaries are in place + product["properties"] = product.get("properties", {}) + product["properties"][prop_dialog.name] = product["properties"].get(property_name, {}) + + prop = { + "unit": prop_dialog.prop["unit"], + "normalize": prop_dialog.prop["normalize"], + "amount": product["properties"][prop_dialog.name].get("amount", 1.0), + } + + # update the property with the new values + product["properties"][prop_dialog.name] = prop + + # if the name has changed, remove the old property + if name_changed and property_name in product["properties"]: + del product["properties"][property_name] + + product.save() + + +class PropertyDialog(QtWidgets.QDialog): + name: str | None = None + prop: dict | None = None + + def __init__(self, process: Process): + super().__init__(app.main_window) + self.process = process + + self.setWindowTitle("Add Property") + + self.prop_name = QtWidgets.QLineEdit(self) + self.prop_name.setPlaceholderText("Property name") + self.prop_name.textChanged.connect(self.validate) + + self.prop_unit = QtWidgets.QLineEdit(self) + self.prop_unit.setPlaceholderText("Property unit") + self.prop_unit.textChanged.connect(self.validate) + + self.normalize_label = QtWidgets.QLabel(" / amount", self) + self.normalize_label.setVisible(False) + self.normalize_check = QtWidgets.QCheckBox("per amount") + self.normalize_check.setChecked(True) + self.normalize_check.toggled.connect(self.normalize_label.setVisible) + self.normalize_check.toggled.connect(self.validate) + + self.buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, + ) + self.buttons.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + + h_layout = QtWidgets.QHBoxLayout() + h_layout.addWidget(self.prop_unit) + h_layout.addWidget(self.normalize_label) + + self.layout = QtWidgets.QVBoxLayout() + self.layout.addWidget(self.prop_name) + self.layout.addLayout(h_layout) + self.layout.addWidget(self.normalize_check, alignment=QtCore.Qt.AlignmentFlag.AlignRight) + self.layout.addWidget(self.buttons) + + self.setLayout(self.layout) + + def validate(self): + if ( + self.prop_name.text() and + self.prop_unit.text() and + self.prop_name.text() not in self.process.get("properties", []) + ): + self.name = self.prop_name.text() + self.prop = { + "unit": self.prop_unit.text(), + "normalize": self.normalize_check.isChecked(), + } + self.buttons.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True) + else: + self.name = None + self.prop = None + self.buttons.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False) diff --git a/activity_browser/app/actions/activity/process_property_remove.py b/activity_browser/app/actions/activity/process_property_remove.py new file mode 100644 index 000000000..c3c731569 --- /dev/null +++ b/activity_browser/app/actions/activity/process_property_remove.py @@ -0,0 +1,60 @@ +from loguru import logger + +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + +from bw_functional import Process +from bw2data import databases + + + + +class ProcessPropertyRemove(ABAction): + """ + Remove a specified property from a process and its associated products. + + This method refreshes the given process, validates its type, and checks if the specified + property exists. If the property is an allocation property, it resets the allocation to + the database's default allocation. The property is then removed from all products of the process. + + Args: + process (tuple | int | Process): The process from which the property will be removed. + Can be a tuple (key), integer (id), or Process object. + property_name (str): The name of the property to remove. + + Raises: + ValueError: If the provided process is not of type Process. + + Logs: + Warning: If the specified property is not found in the process. + """ + + icon = qicons.delete + text = "Remove property" + + @staticmethod + @exception_dialogs + def run(process: tuple | int | Process, property_name: str): + process = refresh_node(process) + if not isinstance(process, Process): + raise ValueError(f"Expected a Process-type activity, got {type(process)} instead") + + allocate = property_name == process.get("allocation") + + if property_name not in process.available_properties(): + logger.warning(f"Property '{property_name}' not found in process {process.key}.") + return + + if allocate: + process["allocation"] = databases[process["database"]].get("default_allocation", "equal") + process.save() + + # Remove the property from all products of the process + for product in process.products(): + if property_name not in product.get("properties", {}): + continue + + del product["properties"][property_name] + product.save() + diff --git a/activity_browser/app/actions/base.py b/activity_browser/app/actions/base.py new file mode 100644 index 000000000..1872a2cf5 --- /dev/null +++ b/activity_browser/app/actions/base.py @@ -0,0 +1,61 @@ +from loguru import logger +from qtpy import QtCore, QtGui, QtWidgets + +from activity_browser import app + + + + +class ABAction: + icon = QtGui.QIcon() + text: str = None + tooltip: str = None + + @staticmethod + def run(*args, **kwargs): + raise NotImplementedError + + @classmethod + def triggered(cls, *args, **kwargs): + args = [arg if not callable(arg) else arg() for arg in args] + kwargs = {k: v if not callable(v) else v() for k, v in kwargs.items()} + + cls.run(*args, **kwargs) + + @classmethod + def get_QAction(cls, *args, parent=None, text=None, enabled=True, **kwargs) -> QtWidgets.QAction: + text = text or cls.text + action = QtWidgets.QAction(cls.icon, text, parent, enabled=enabled) + action.setToolTip(cls.tooltip) + + action.triggered.connect(lambda: cls.triggered(*args, **kwargs)) + + return action + + @classmethod + def get_QButton(cls, *args, **kwargs): + """Convenience function to return a button that has this ABAction as default action.""" + button = QtWidgets.QPushButton( + cls.icon, + cls.text + ) + button.clicked.connect(lambda x: cls.triggered(*args, **kwargs)) + return button + + +def exception_dialogs(func): + def wrapper(*args, **kwargs): + try: + func(*args, **kwargs) + except Exception as e: + if not hasattr(e, "dialog_flag"): + setattr(e, "dialog_flag", True) + QtWidgets.QMessageBox.critical( + app.main_window, + f"An error occurred: {type(e).__name__}", + f"An error occurred, check the logs for more information \n\n {str(e)}", + QtWidgets.QMessageBox.Ok, + ) + raise e + + return wrapper diff --git a/activity_browser/app/actions/calculation_setup/cs_add_functional_unit.py b/activity_browser/app/actions/calculation_setup/cs_add_functional_unit.py new file mode 100644 index 000000000..2171ee4f7 --- /dev/null +++ b/activity_browser/app/actions/calculation_setup/cs_add_functional_unit.py @@ -0,0 +1,23 @@ +from loguru import logger + +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd + + + + +class CSAddFunctionalUnit(ABAction): + text = "Add Functional Unit to Calculation Setup" + + @staticmethod + @exception_dialogs + def run(cs_name: str, activities: list[tuple | int | bd.Node]): + activities = [refresh_node(node) for node in activities] + calculation_setup = bd.calculation_setups[cs_name] + + fus = [{act.key: -1.0 if act.get("type") == "waste" else 1.0} for act in activities] + calculation_setup['inv'] += fus + + bd.calculation_setups[cs_name] = calculation_setup + bd.calculation_setups.serialize() diff --git a/activity_browser/app/actions/calculation_setup/cs_add_impact_category.py b/activity_browser/app/actions/calculation_setup/cs_add_impact_category.py new file mode 100644 index 000000000..69a5a34c5 --- /dev/null +++ b/activity_browser/app/actions/calculation_setup/cs_add_impact_category.py @@ -0,0 +1,21 @@ +from loguru import logger + +import bw2data as bd + +from activity_browser.app.actions.base import ABAction, exception_dialogs + + + + +class CSAddImpactCategory(ABAction): + text = "Add Impact Category to Calculation Setup" + + @staticmethod + @exception_dialogs + def run(cs_name: str, ic_names: list[str]): + calculation_setup = bd.calculation_setups[cs_name] + + calculation_setup['ia'] += ic_names + + bd.calculation_setups[cs_name] = calculation_setup + bd.calculation_setups.serialize() diff --git a/activity_browser/app/actions/calculation_setup/cs_calculate.py b/activity_browser/app/actions/calculation_setup/cs_calculate.py new file mode 100644 index 000000000..f5e8830eb --- /dev/null +++ b/activity_browser/app/actions/calculation_setup/cs_calculate.py @@ -0,0 +1,76 @@ +from loguru import logger + +import pandas as pd +import bw2data as bd + +from qtpy import QtCore, QtWidgets + +from activity_browser import app +from activity_browser.bwutils.multilca import MLCA, Contributions +from activity_browser.bwutils.superstructure import SuperstructureMLCA, SuperstructureContributions +from activity_browser.bwutils.montecarlo import MonteCarloLCA +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + + + + +class CSCalculate(ABAction): + """ + ABAction to calculate a calculation setup. First asks the user for confirmation and returns if cancelled. Otherwise, + passes the csname to the CalculationSetupController for calculation. Finally, displays confirmation that it succeeded. + """ + + icon = qicons.calculate + text = "Calculate" + + @staticmethod + @exception_dialogs + def run(cs_name: str, scenario_data: pd.DataFrame = None): + from activity_browser.app import pages + + # Check if the calculation setup is complete + if cs_name not in bd.calculation_setups: + raise Exception(f"Calculation setup '{cs_name}' not found.") + cs = bd.calculation_setups[cs_name] + if not cs.get("inv"): + raise Exception(f"Calculation setup '{cs_name}' has no functional units.") + if not cs.get("ia"): + raise Exception(f"Calculation setup '{cs_name}' has no impact assessment methods.") + + dialog = CalculationDialog(cs_name, app.main_window) + dialog.show() + app.application.thread().eventDispatcher().processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + + try: + if scenario_data is None: + mlca = MLCA(cs_name) + contributions = Contributions(mlca) + else: + mlca = SuperstructureMLCA(cs_name, scenario_data) + contributions = SuperstructureContributions(mlca) + + mlca.calculate() + mc = MonteCarloLCA(cs_name) + + page = pages.LCAResultsPage(cs_name, mlca, contributions, mc) + central = app.main_window.centralWidget() + except: + dialog.close() + raise + + dialog.close() + central.addToGroup("LCA Results", page) + + +class CalculationDialog(QtWidgets.QDialog): + def __init__(self, cs_name: str, parent=None): + super().__init__(parent, QtCore.Qt.WindowTitleHint) + self.setWindowTitle(f"Running Calculations") + self.setModal(True) + + self.label = QtWidgets.QLabel(f"Running calculations for setup: {cs_name}", self) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.label) + self.setLayout(layout) diff --git a/activity_browser/app/actions/calculation_setup/cs_change_functional_unit.py b/activity_browser/app/actions/calculation_setup/cs_change_functional_unit.py new file mode 100644 index 000000000..f7029170a --- /dev/null +++ b/activity_browser/app/actions/calculation_setup/cs_change_functional_unit.py @@ -0,0 +1,40 @@ +from loguru import logger + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd + + + + +class CSChangeFunctionalUnit(ABAction): + """ + Updates the functional unit amount for a specific inventory item in a calculation setup. + + This method modifies the amount of a functional unit in the inventory of a given calculation setup + and saves the updated setup. + + Args: + cs_name (str): The name of the calculation setup to modify. + index (int): The index of the inventory item within the calculation setup. + amount (float): The new amount to set for the functional unit. + + Steps: + - Retrieve the calculation setup by its name. + - Extract the key of the inventory item at the specified index. + - Update the amount for the specified inventory item. + - Serialize and save the updated calculation setup. + + Raises: + Exception: If an error occurs during the process, it is handled by the `exception_dialogs` decorator. + """ + text = "Add Functional Unit to Calculation Setup" + + @staticmethod + @exception_dialogs + def run(cs_name: str, index: int, amount: float): + calculation_setup = bd.calculation_setups[cs_name] + + key = list(calculation_setup['inv'][index].keys())[0] + calculation_setup['inv'][index][key] = amount + + bd.calculation_setups.serialize() diff --git a/activity_browser/app/actions/calculation_setup/cs_delete.py b/activity_browser/app/actions/calculation_setup/cs_delete.py new file mode 100644 index 000000000..47b88ab29 --- /dev/null +++ b/activity_browser/app/actions/calculation_setup/cs_delete.py @@ -0,0 +1,47 @@ +from loguru import logger + +from qtpy import QtWidgets + +from activity_browser.app import application +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + + + + +class CSDelete(ABAction): + """ + ABAction to delete a calculation setup. First asks the user for confirmation and returns if cancelled. Otherwise, + passes the csname to the CalculationSetupController for deletion. Finally, displays confirmation that it succeeded. + """ + + icon = qicons.delete + text = "Delete" + + @staticmethod + @exception_dialogs + def run(cs_names: str | list[str]): + if isinstance(cs_names, str): + cs_names = [cs_names] + + # ask the user whether they are sure to delete the calculation setup + warning = QtWidgets.QMessageBox.warning( + application.main_window, + f"Deleting Calculation Setup(s): {', '.join(cs_names)}", + "Are you sure?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + + # return if the users cancels + if warning == QtWidgets.QMessageBox.No: + return + + for cs_name in cs_names: + if cs_name not in bd.calculation_setups: + logger.warning(f"Calculation setup {cs_name} not found") + continue + + del bd.calculation_setups[cs_name] + logger.info(f"Deleted calculation setup: {cs_name}") diff --git a/activity_browser/app/actions/calculation_setup/cs_delete_functional_unit.py b/activity_browser/app/actions/calculation_setup/cs_delete_functional_unit.py new file mode 100644 index 000000000..654f311e2 --- /dev/null +++ b/activity_browser/app/actions/calculation_setup/cs_delete_functional_unit.py @@ -0,0 +1,22 @@ +from loguru import logger + +import bw2data as bd + +from activity_browser.app.actions.base import ABAction, exception_dialogs + + + + +class CSDeleteFunctionalUnit(ABAction): + text = "Delete Functional Unit" + + @staticmethod + @exception_dialogs + def run(cs_name: str, indices: list[int]): + calculation_setup = bd.calculation_setups[cs_name] + + for index in sorted(set(indices), reverse=True): + del calculation_setup['inv'][index] + + bd.calculation_setups[cs_name] = calculation_setup + bd.calculation_setups.serialize() diff --git a/activity_browser/app/actions/calculation_setup/cs_delete_impact_category.py b/activity_browser/app/actions/calculation_setup/cs_delete_impact_category.py new file mode 100644 index 000000000..fa4ad031a --- /dev/null +++ b/activity_browser/app/actions/calculation_setup/cs_delete_impact_category.py @@ -0,0 +1,22 @@ +from loguru import logger + +import bw2data as bd + +from activity_browser.app.actions.base import ABAction, exception_dialogs + + + + +class CSDeleteImpactCategory(ABAction): + text = "Delete Impact Category" + + @staticmethod + @exception_dialogs + def run(cs_name: str, indices: list[int]): + calculation_setup = bd.calculation_setups[cs_name] + + for index in sorted(set(indices), reverse=True): + del calculation_setup['ia'][index] + + bd.calculation_setups[cs_name] = calculation_setup + bd.calculation_setups.serialize() diff --git a/activity_browser/app/actions/calculation_setup/cs_duplicate.py b/activity_browser/app/actions/calculation_setup/cs_duplicate.py new file mode 100644 index 000000000..aa49be61a --- /dev/null +++ b/activity_browser/app/actions/calculation_setup/cs_duplicate.py @@ -0,0 +1,49 @@ +from loguru import logger + +from qtpy import QtWidgets + +from activity_browser.app import application +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + +from .cs_open import CSOpen + + + +class CSDuplicate(ABAction): + """ + ABAction to duplicate a calculation setup. Prompts the user for a new name. Returns if the user cancels, or if a CS + with the same name is already present within the project. If all is right, instructs the CalculationSetupController + to duplicate the CS. + """ + + icon = qicons.copy + text = "Duplicate" + + @staticmethod + @exception_dialogs + def run(cs_name: str): + # prompt the user to give a name for the new calculation setup + new_name, ok = QtWidgets.QInputDialog.getText( + application.main_window, + f"Duplicate '{cs_name}'", + "Name of the duplicated calculation setup:" + " " * 10, + ) + + # return if the user cancels or gives no name + if not ok or not new_name: + return + + # throw error if the name is already present, and return + if new_name in bd.calculation_setups: + QtWidgets.QMessageBox.warning( + application.main_window, + "Not possible", + "A calculation setup with this name already exists.", + ) + return + + bd.calculation_setups[new_name] = bd.calculation_setups[cs_name].copy() + logger.info(f"Copied calculation setup {cs_name} as {new_name}") + CSOpen.run(new_name) diff --git a/activity_browser/app/actions/calculation_setup/cs_new.py b/activity_browser/app/actions/calculation_setup/cs_new.py new file mode 100644 index 000000000..c5d3cedd7 --- /dev/null +++ b/activity_browser/app/actions/calculation_setup/cs_new.py @@ -0,0 +1,91 @@ +from loguru import logger + +from qtpy import QtWidgets + +import bw2data as bd + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.ui.icons import qicons + + + + +class CSNew(ABAction): + """ + Create a new Calculation Setup. + + This method prompts the user for a name for the new Calculation Setup (CS) if not provided. + It validates the name to ensure it is unique within the project and creates a new CS + with the specified functional units and impact categories. + + Args: + name (str, optional): The name of the new Calculation Setup. If not provided, the user is prompted. + functional_units (list[dict[tuple | int | bd.Node, float]], optional): A list of functional units to include in the CS. + impact_categories (list[tuple], optional): A list of impact categories to include in the CS. + + Returns: + None: Returns early if the user cancels, provides no name, or if the name already exists. + + Raises: + None: This method does not raise exceptions but logs errors and shows warnings for invalid inputs. + """ + + icon = qicons.add + text = "New calculation setup..." + + @staticmethod + @exception_dialogs + def run(name: str = None, + functional_units: list[dict[tuple | int | bd.Node, float]] = None, + impact_categories: list[tuple] = None + ): + + name = name or CSNew.get_cs_name() + + # return if the user cancels or gives no name + if not name: + return + + # throw error if the name is already present, and return + if name in bd.calculation_setups: + QtWidgets.QMessageBox.warning( + app.main_window, + "Not possible", + "A calculation setup with this name already exists.", + ) + return + + inv = functional_units or [] + for i, fu in enumerate(inv): + if not isinstance(fu, dict): + raise TypeError("Functional units must be a list of dictionaries.") + refreshed = {refresh_node(key).key: amount for key, amount in fu.items()} + inv[i] = refreshed + + ia = impact_categories or [] + + # instruct the CalculationSetupController to create a CS with the new name + bd.calculation_setups[name] = {"inv": inv, "ia": ia} + + logger.info(f"New calculation setup: {name}") + + app.actions.CSOpen.run(name) + + @staticmethod + def get_cs_name() -> str | None: + """ + Prompt the user for a name for the new calculation setup. + """ + # prompt the user to give a name for the new calculation setup + name, ok = QtWidgets.QInputDialog.getText( + app.main_window, + "Create new calculation setup", + "Name of new calculation setup:" + " " * 10, + ) + + # return if the user cancels or gives no name + if not ok or not name: + return None + return name diff --git a/activity_browser/app/actions/calculation_setup/cs_open.py b/activity_browser/app/actions/calculation_setup/cs_open.py new file mode 100644 index 000000000..688707b93 --- /dev/null +++ b/activity_browser/app/actions/calculation_setup/cs_open.py @@ -0,0 +1,25 @@ +from loguru import logger + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd + + +class CSOpen(ABAction): + text = "Open" + + @staticmethod + @exception_dialogs + def run(cs_names: str | list[str]): + if isinstance(cs_names, str): + cs_names = [cs_names] + + for cs_name in cs_names: + if cs_name not in bd.calculation_setups: + logger.warning(f"Calculation setup {cs_name} not found") + continue + + page = app.pages.CalculationSetupPage(cs_name) + central = app.main_window.centralWidget() + + central.addToGroup("LCA Setup", page) diff --git a/activity_browser/app/actions/calculation_setup/cs_rename.py b/activity_browser/app/actions/calculation_setup/cs_rename.py new file mode 100644 index 000000000..72ad71041 --- /dev/null +++ b/activity_browser/app/actions/calculation_setup/cs_rename.py @@ -0,0 +1,49 @@ +from loguru import logger + +from qtpy import QtWidgets + +from activity_browser.app import application, signals +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + + + + +class CSRename(ABAction): + """ + ABAction to rename a calculation setup. Prompts the user for a new name. Returns if the user cancels, or if a CS + with the same name is already present within the project. If all is right, instructs the CalculationSetupController + to rename the CS. + """ + + icon = qicons.edit + text = "Rename" + + @staticmethod + @exception_dialogs + def run(cs_name: str, new_name: str = None): + # prompt the user to give a name for the new calculation setup + new_name, ok = QtWidgets.QInputDialog.getText( + application.main_window, + f"Rename '{cs_name}'", + "New name of this calculation setup:" + " " * 10, + ) + + # return if the user cancels or gives no name + if not ok or not new_name: + return + + # throw error if the name is already present, and return + if new_name in bd.calculation_setups: + QtWidgets.QMessageBox.warning( + application.main_window, + "Not possible", + "A calculation setup with this name already exists.", + ) + return + + # instruct the CalculationSetupController to rename the CS to the new name + bd.calculation_setups[new_name] = bd.calculation_setups[cs_name].copy() + del bd.calculation_setups[cs_name] + logger.info(f"Renamed calculation setup from {cs_name} to {new_name}") diff --git a/activity_browser/app/actions/database/database_delete.py b/activity_browser/app/actions/database/database_delete.py new file mode 100644 index 000000000..81af59e79 --- /dev/null +++ b/activity_browser/app/actions/database/database_delete.py @@ -0,0 +1,96 @@ +from typing import List + +from qtpy import QtCore, QtWidgets + +import bw2data as bd +from bw2data.parameters import Group +from bw2data.backends.proxies import ExchangeDataset, Exchanges + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + + +class DatabaseDelete(ABAction): + """ + Deletes one or more databases from the project after user confirmation. + + This method performs the following steps: + - Displays a confirmation dialog to the user with the database name(s) and total record count. + - If the user confirms, deletes the database(s), their upstream exchanges, and associated parameters. + - Removes the database(s) from the project settings. + + Args: + db_names (List[str]): The name(s) of the database(s) to be deleted. + + Steps: + - Set the cursor to a waiting state while gathering data for large databases. + - Retrieve the record count for the specified database(s). + - Construct a warning message with the database name(s) and record count. + - Display a confirmation dialog to the user. + - If the user cancels, exit the method. + - Set the cursor to a waiting state while performing the deletion. + - Delete upstream exchanges associated with the database(s). + - Remove the database(s) from the Brightway2 project. + - Delete database parameters. + - Remove the database(s) from the project settings. + - Restore the cursor to its default state. + """ + + icon = qicons.delete + text = "Delete databases" + tool_tip = "Delete database(s) from the project" + + @staticmethod + @exception_dialogs + def run(db_names: List[str]): + # gathering data will take time for large databases + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + + # get the total record count from all databases + total_records = 0 + for db_name in db_names: + n_records = app.metadata.dataframe[app.metadata.dataframe["database"] == db_name].shape[0] + total_records += n_records + + # construct warning text + if len(db_names) == 1: + text = f"Are you sure you want to delete database '{db_names[0]}'?" + if total_records: + text += f" It contains {total_records} activities." + else: + text = f"Are you sure you want to delete {len(db_names)} databases?" + if total_records: + text += f" They contain {total_records} activities in total." + + # ask the user for confirmation + QtWidgets.QApplication.restoreOverrideCursor() + response = QtWidgets.QMessageBox.question( + app.main_window, build_title(db_names), text + ) + + # return if the user cancels + if response != QtWidgets.QMessageBox.Yes: + return + + # deleting data will take time for large databases + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + + for db_name in db_names: + # delete upstream exchanges + ExchangeDataset.delete().where(ExchangeDataset.input_database == db_name).execute() + + # instruct the DatabaseController to delete the database from the project. + del bd.databases[db_name] + + # delete database parameters + Group.delete().where(Group.name == db_name).execute() + + QtWidgets.QApplication.restoreOverrideCursor() + + +def build_title(db_names: List[str]) -> str: + """Build an appropriate title for the confirmation dialog.""" + if len(db_names) == 1: + return "Delete database?" + return "Delete databases?" diff --git a/activity_browser/app/actions/database/database_duplicate.py b/activity_browser/app/actions/database/database_duplicate.py new file mode 100644 index 000000000..3ca59654b --- /dev/null +++ b/activity_browser/app/actions/database/database_duplicate.py @@ -0,0 +1,102 @@ +import copy + +from qtpy import QtWidgets + +import bw2data as bd +import bw_functional as bf + +from activity_browser.app import application +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons +from activity_browser.ui.core.threading import ABThread + +from .database_new import NewDatabaseDialog + + +class DatabaseDuplicate(ABAction): + """ + ABAction to duplicate a database. Asks the user to provide a new name for the database, and returns when the name is + already in use by an existing database. Then it shows a progress dialogue which will construct a new thread in which + the database duplication will take place. This thread instructs the DatabaseController to duplicate the selected + database with the chosen name. + """ + + icon = qicons.duplicate_database + text = "Duplicate database..." + tool_tip = "Make a duplicate of this database" + + @staticmethod + @exception_dialogs + def run(db_name: str): + assert db_name in bd.databases + backend = bd.databases[db_name].get("backend", "undefined") + + if backend not in ["sqlite", "functional_sqlite"]: + QtWidgets.QMessageBox.information( + application.main_window, + "Not possible", + f"Unsupported database backend {backend}", + ) + return + + name, backend, ok = NewDatabaseDialog.get_new_database_data(window_title="Duplicate database", backend=backend) + + if not name or not ok: + return + + if name in bd.databases: + QtWidgets.QMessageBox.information( + application.main_window, + "Not possible", + "A database with this name already exists.", + ) + return + + DuplicateDatabaseDialog(db_name, name, backend, application.main_window) + + +class DuplicateDatabaseDialog(QtWidgets.QProgressDialog): + def __init__(self, from_db: str, to_db: str, backend: str, parent=None): + super().__init__(parent=parent) + self.setWindowTitle("Duplicating database") + self.setLabelText( + f"Duplicating existing database {from_db} to new database {to_db}:" + ) + self.setModal(True) + self.setRange(0, 0) + + self.dup_thread = DuplicateDatabaseThread(application) + self.dup_thread.finished.connect(self.thread_finished) + + self.show() + + self.dup_thread.start(from_db, to_db, backend) + + def thread_finished(self) -> None: + self.dup_thread.exit(0) + self.setMaximum(1) + self.setValue(1) + + +class DuplicateDatabaseThread(ABThread): + + def run_safely(self, copy_from, copy_to, backend): + database = bd.Database(copy_from) + + data = database.load() + data = database.relabel_data(data, copy_from, copy_to) + + new_database = bd.Database(copy_to, backend=backend) + + metadata = copy.copy(database.metadata) + metadata["format"] = f"Copied from '{copy_from}'" + metadata["backend"] = backend + new_database.register(write_empty=False, **metadata) + + if database.backend == "sqlite" and backend == "functional_sqlite": + data = bf.convert_sqlite_to_functional_sqlite(data) + elif database.backend == "functional_sqlite" and backend == "sqlite": + data = bf.convert_functional_sqlite_to_sqlite(data) + + new_database.write(data, searchable=metadata.get("searchable")) + return new_database diff --git a/activity_browser/app/actions/database/database_explorer_open.py b/activity_browser/app/actions/database/database_explorer_open.py new file mode 100644 index 000000000..219c0d32e --- /dev/null +++ b/activity_browser/app/actions/database/database_explorer_open.py @@ -0,0 +1,21 @@ +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + + +class DatabaseExplorerOpen(ABAction): + """ + ABAction to delete a database from the project. Asks the user for confirmation. If confirmed, instructs the + DatabaseController to delete the database in question. + """ + + icon = qicons.delete + text = "Explore database" + tool_tip = "Delete this database from the project" + + @staticmethod + @exception_dialogs + def run(db_name: str): + from activity_browser.app.panes import DatabaseExplorerPane + db_explorer = DatabaseExplorerPane(db_name, app.main_window) + db_explorer.show() diff --git a/activity_browser/app/actions/database/database_export_bw2package.py b/activity_browser/app/actions/database/database_export_bw2package.py new file mode 100644 index 000000000..db5fdd166 --- /dev/null +++ b/activity_browser/app/actions/database/database_export_bw2package.py @@ -0,0 +1,99 @@ +from loguru import logger +from typing import List + +from qtpy import QtWidgets + +from activity_browser.app import application, dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui import widgets +from activity_browser.bwutils import exporters +from activity_browser.ui.core import threading + + +class DatabaseExportBW2Package(ABAction): + """ + ABAction to export database(s) to BW2Package format (.bw2package). + """ + + # icon = icons.qicons.export_db + text = "Export to .bw2package" + tool_tip = "Export database(s) to BW2Package format" + + @classmethod + @exception_dialogs + def run(cls, db_names: List[str] = None): + if db_names is None: + import bw2data as bd + dialog = dialogs.DatabaseSelectDialog( + parent=application.main_window, + databases=sorted(bd.databases), + title="Select databases to export to BW2Package" + ) + if dialog.exec_() == QtWidgets.QDialog.Accepted: + db_names = dialog.get_selected_databases() + else: + return + + # Get export directory or file from user + if len(db_names) == 1: + # Single database - suggest a filename + suggested_name = f"{db_names[0]}.bw2package" + path, _ = QtWidgets.QFileDialog.getSaveFileName( + parent=application.main_window, + caption=f'Export database "{db_names[0]}" to BW2Package', + directory=suggested_name, + filter='Brightway2 Database Package (*.bw2package);; All files (*.*)' + ) + else: + # Multiple databases - ask for directory + path = QtWidgets.QFileDialog.getExistingDirectory( + parent=application.main_window, + caption=f'Select directory to export {len(db_names)} databases', + ) + + if not path: + return + + # Show export dialog + context = { + "db_names": db_names, + "path": path, + } + export_dialog = ExportBW2PackageSetup( + parent=application.main_window, + title="Export to BW2Package", + context=context + ) + export_dialog.show() + + +class ExportBW2PackageSetup(widgets.ABWizard): + """Wizard for exporting databases to BW2Package format.""" + + class ExportPage(widgets.ABThreadedWizardPage): + """Wizard page to export the selected database(s) to BW2Package.""" + title = "Exporting Database(s)" + subtitle = "Exporting database(s) to .bw2package file(s)" + + class Thread(threading.ABThread): + """Thread to handle the export process.""" + + def run_safely(self, db_names: List[str], path: str): + """Export the database(s) to BW2Package.""" + for db_name in db_names: + try: + success = exporters.store_database_as_package(db_name, path) + if success: + logger.info(f"Successfully exported database '{db_name}' to BW2Package") + else: + logger.error(f"Failed to export database '{db_name}'") + raise RuntimeError(f"Database '{db_name}' not found") + except Exception as e: + logger.error(f"Failed to export database '{db_name}': {e}") + raise + + def initializePage(self, context: dict): + """Start the export thread.""" + self.thread.start(context["db_names"], context["path"]) + + pages = [ExportPage] diff --git a/activity_browser/app/actions/database/database_export_excel.py b/activity_browser/app/actions/database/database_export_excel.py new file mode 100644 index 000000000..319c83e90 --- /dev/null +++ b/activity_browser/app/actions/database/database_export_excel.py @@ -0,0 +1,95 @@ +from loguru import logger +from typing import List + +from qtpy import QtWidgets + +from activity_browser.app import application, dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui import widgets +from activity_browser.bwutils import exporters +from activity_browser.ui.core import threading + + +class DatabaseExportExcel(ABAction): + """ + ABAction to export database(s) to Excel format (.xlsx). + """ + + icon = application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) + text = "Export to Excel (.xlsx)" + tool_tip = "Export database(s) to Excel format" + + @classmethod + @exception_dialogs + def run(cls, db_names: List[str] = None): + if db_names is None: + import bw2data as bd + dialog = dialogs.DatabaseSelectDialog( + parent=application.main_window, + databases=sorted(bd.databases), + title="Select databases to export to Excel" + ) + if dialog.exec_() == QtWidgets.QDialog.Accepted: + db_names = dialog.get_selected_databases() + else: + return + + # Get export directory or file from user + if len(db_names) == 1: + # Single database - suggest a filename + suggested_name = f"lci-{db_names[0]}.xlsx" + path, _ = QtWidgets.QFileDialog.getSaveFileName( + parent=application.main_window, + caption=f'Export database "{db_names[0]}" to Excel', + directory=suggested_name, + filter='Excel spreadsheet (*.xlsx);; All files (*.*)' + ) + else: + # Multiple databases - ask for directory + path = QtWidgets.QFileDialog.getExistingDirectory( + parent=application.main_window, + caption=f'Select directory to export {len(db_names)} databases', + ) + + if not path: + return + + # Show export dialog + context = { + "db_names": db_names, + "path": path, + } + export_dialog = ExportExcelSetup( + parent=application.main_window, + title="Export to Excel", + context=context + ) + export_dialog.show() + + +class ExportExcelSetup(widgets.ABWizard): + """Wizard for exporting databases to Excel format.""" + + class ExportPage(widgets.ABThreadedWizardPage): + """Wizard page to export the selected database(s) to Excel.""" + title = "Exporting Database(s)" + subtitle = "Exporting database(s) to Excel file(s)" + + class Thread(threading.ABThread): + """Thread to handle the export process.""" + + def run_safely(self, db_names: List[str], path: str): + """Export the database(s) to Excel.""" + for db_name in db_names: + try: + exporters.write_lci_excel(db_name, path) + logger.info(f"Successfully exported database '{db_name}' to Excel") + except Exception as e: + logger.error(f"Failed to export database '{db_name}': {e}") + raise + + def initializePage(self, context: dict): + """Start the export thread.""" + self.thread.start(context["db_names"], context["path"]) + + pages = [ExportPage] diff --git a/activity_browser/app/actions/database/database_import_from_ecoinvent.py b/activity_browser/app/actions/database/database_import_from_ecoinvent.py new file mode 100644 index 000000000..10e89eb5e --- /dev/null +++ b/activity_browser/app/actions/database/database_import_from_ecoinvent.py @@ -0,0 +1,477 @@ +import re +import os +from loguru import logger +from copy import deepcopy + +import requests + +import ecoinvent_interface as ei +import bw2data as bd +import bw2io as bi + +from qtpy import QtWidgets, QtCore +from qtpy.QtCore import Signal, SignalInstance + +from activity_browser.app import application, signals +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.mod.bw2io.migrations import ab_create_core_migrations +from activity_browser.ui.core import threading + + + + +class DatabaseImportFromEcoinvent(ABAction): + """ + Launches the `EiWizard` dialog for importing a database from ecoinvent. + + This method creates an instance of the `EiWizard` class, passing the + main application window as its parent, and executes the wizard dialog. + + Raises: + Any exceptions encountered during the execution of the wizard + are handled by the `exception_dialogs` decorator. + """ + + icon = icons.qicons.import_db + text = "Import database from ecoinvent" + tool_tip = "Import database from ecoinvent" + + @staticmethod + @exception_dialogs + def run(): + setup = EiWizard(application.main_window) + setup.setWindowTitle("Import from ecoinvent") + setup.exec_() + + +class EiWizard(widgets.ABWizard): + """Wizard for importing database from ecoinvent""" + + class RemoteOrLocalPage(widgets.ABWizardPage): + """Wizard page to choose between remote or local ecoinvent release""" + title = "Import from ecoinvent" + subtitle = "Choose whether to import from a remote or local ecoinvent release." + buttonLayout = ["Stretch", "CancelButton", "NextButton"] + + def __init__(self, parent=None): + super().__init__(parent) + + self.remote_button = QtWidgets.QRadioButton("Remote") + self.local_button = QtWidgets.QRadioButton("Local") + self.remote_button.setChecked(True) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.remote_button) + layout.addWidget(self.local_button) + self.setLayout(layout) + + def nextPage(self): + """Determine the next page based on the user's selection""" + if self.local_button.isChecked(): + return EiWizard.LocalSelectPage + else: + return EiWizard.LoginPage + + class LocalSelectPage(widgets.ABWizardPage): + """Wizard page to select a local ecoinvent .7z file""" + title = "Import from ecoinvent" + subtitle = "Select local ecoinvent .7z." + buttonLayout = ["Stretch", "CancelButton", "BackButton", "NextButton"] + + def __init__(self, parent=None): + super().__init__(parent) + + self.file_selector = widgets.ABFileSelector(filter="*.7z") + self.file_selector.textChanged.connect(lambda: self.completeChanged.emit()) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.file_selector) + self.setLayout(layout) + + def finalize(self, context: dict): + """Store the selected file path in the context""" + path = self.file_selector.text() + context["ei_filepath"] = path + # Try to extract version and model from the filename + if version:= re.search(r'ecoinvent ([\d.]+)', path): + context["version"] = version.group(1) + if model:= re.search(r'ecoinvent [\d.]+_([^_]+)', path): + context["model"] = model.group(1) + + def isComplete(self): + """Check if a file has been selected""" + return bool(self.file_selector.text()) + + def nextPage(self): + """Proceed to the BiosphereSetupPage""" + return EiWizard.BiosphereSetupPage + + class LoginPage(widgets.ABWizardPage): + """Wizard page to login with ecoinvent credentials""" + title = "Login" + subtitle = "Login with your ecoinvent credentials to authorize the download" + buttonLayout = ["Stretch", "CancelButton", "BackButton", "NextButton"] + + def __init__(self, parent=None): + super().__init__(parent) + + self.release = None + + self.username = QtWidgets.QLineEdit() + self.username.setPlaceholderText('ecoinvent username') + + self.password = QtWidgets.QLineEdit() + self.password.setPlaceholderText('ecoinvent password') + self.password.setEchoMode(QtWidgets.QLineEdit.Password) + + self.message = QtWidgets.QLabel() + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.username) + layout.addWidget(self.password) + layout.addWidget(self.message) + + self.setLayout(layout) + + def initializePage(self, context: dict): + """Initialize the page with stored username and password""" + settings = ei.Settings() + self.username.setText(settings.username) + self.password.setText(settings.password) + + def validatePage(self): + """Validate the login credentials by attempting to list ecoinvent versions""" + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + try: + settings = ei.Settings(username=self.username.text(), password=self.password.text()) + self.release = ei.EcoinventRelease(settings) + self.release.list_versions() + except requests.exceptions.HTTPError as e: + QtWidgets.QApplication.restoreOverrideCursor() + if e.response.status_code == 401: + self.message.setText("Invalid username and/or password, please try again.") + return False + else: + self.message.setText("Unknown connection error, try again later.") + raise e + ei.permanent_setting("username", self.username.text()) + ei.permanent_setting("password", self.password.text()) + QtWidgets.QApplication.restoreOverrideCursor() + return True + + def finalize(self, context: dict): + """Store the release object in the context""" + context["release"] = self.release + + def nextPage(self): + """Proceed to the EcoinventVersionPage""" + return EiWizard.EcoinventVersionPage + + class EcoinventVersionPage(widgets.ABWizardPage): + """Wizard page to choose ecoinvent version and system model""" + title = "Choose version" + subtitle = "Choose ecoinvent version and system model" + buttonLayout = ["Stretch", "CancelButton", "BackButton", "NextButton"] + + def __init__(self, parent=None): + super().__init__(parent) + + self.versions = QtWidgets.QComboBox() + self.models = QtWidgets.QComboBox() + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.versions) + layout.addWidget(self.models) + + self.setLayout(layout) + + def initializePage(self, context: dict): + """Initialize the page with available versions and models""" + self.release = context["release"] + self.versions.currentTextChanged.connect(self.collect_models) + self.versions.addItems(self.release.list_versions()) + + def finalize(self, context: dict): + """Store the selected version and model in the context""" + context["version"] = self.versions.currentText() + context["model"] = self.models.currentText() + + def nextPage(self): + """Proceed to the EcoinventDownloadPage""" + return EiWizard.EcoinventDownloadPage + + def collect_models(self, version: str): + """Collect and display system models for the selected version""" + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + self.models.clear() + self.models.addItems(self.release.list_system_models(version)) + QtWidgets.QApplication.restoreOverrideCursor() + + class EcoinventDownloadPage(widgets.ABThreadedWizardPage): + """Wizard page to download the selected ecoinvent release""" + title = "Download ecoinvent" + subtitle = "Downloading the selected ecoinvent release" + buttonLayout = ["Stretch", "NextButton"] + + class Thread(threading.ABThread): + """Thread to handle the download process""" + download_ready: SignalInstance = Signal(str) + + def run_safely(self, release: ei.release, version: str, model: str): + """Download the ecoinvent release""" + path = release.get_release( + version=version, + system_model=model, + release_type=ei.ReleaseType.ecospold, + extract=False, + fix_version=False + ) + path = str(path) + if not path.endswith(".7z") and os.path.exists(path + ".7z"): + path = path + ".7z" + self.download_ready.emit(path) + + def __init__(self, parent=None): + super().__init__(parent) + self.ei_filepath = None + + def initializePage(self, context: dict): + """Start the download thread""" + self.thread.start(context["release"], context["version"], context["model"]) + self.thread.download_ready.connect(self.download_ready) + + def download_ready(self, filepath: str): + """Handle the completion of the download""" + self.ei_filepath = filepath + + def finalize(self, context: dict): + """Store the downloaded file path in the context""" + context["ei_filepath"] = self.ei_filepath + + def nextPage(self): + """Proceed to the BiosphereSetupPage""" + return EiWizard.BiosphereSetupPage + + class BiosphereSetupPage(widgets.ABWizardPage): + """Wizard page to choose biosphere setup options""" + title = "Biosphere setup" + subtitle = "Choose whether to import the biosphere database or connect to an existing one" + buttonLayout = ["Stretch", "CancelButton", "CommitButton"] + + def __init__(self, parent=None): + super().__init__(parent) + + self.biosphere_choice = widgets.ABRadioButtonCollapser(self) + self.biosphere_choice.buttonClicked.connect(lambda: self.completeChanged.emit()) + + self.biosphere_choice.addOption( + name="existing", + label="Link to an existing biosphere", + w=widgets.ABComboBox.get_database_combobox() + ) + + self.biosphere_choice.addOption( + name="import", + label="Import included biosphere", + w=widgets.DatabaseNameEdit(database_preset="biosphere") + ) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.biosphere_choice) + self.setLayout(layout) + + def isComplete(self): + """Check if a biosphere option has been selected""" + return self.biosphere_choice.currentOption() is not None + + def initializePage(self, context: dict): + """Start the biosphere installation thread""" + if "version" in context: + self.biosphere_choice.view("import").setText(f"biosphere-{context['version']}") + + def finalize(self, context: dict): + """Store the selected biosphere option in the context""" + if self.biosphere_choice.currentOption() == "existing": + context["biosphere_name"] = self.biosphere_choice.view("existing").currentText() + else: + context["biosphere_name"] = self.biosphere_choice.view("import").text() + + def nextPage(self): + """Proceed to the appropriate next page based on the biosphere choice""" + if self.biosphere_choice.currentOption() == "existing": + return EiWizard.EcoinventSetupPage + else: + return EiWizard.BiosphereInstallPage + + class BiosphereInstallPage(widgets.ABThreadedWizardPage): + """Wizard page to install the biosphere database""" + title = "Installing biosphere database" + subtitle = "Installing bundled biosphere database into the project" + buttonLayout = ["Stretch", "NextButton"] + + class Thread(threading.ABThread): + """Thread to handle the biosphere installation process""" + def run_safely(self, ei_filepath: str, biosphere_name: str): + """Install the biosphere database""" + importer = Ecoinvent7zImporter(ei_filepath) + importer.install_biosphere(biosphere_name) + + def initializePage(self, context: dict): + """Start the biosphere installation thread""" + self.thread.start(context["ei_filepath"], context["biosphere_name"]) + + def nextPage(self): + """Proceed to the MethodsSetupPage""" + return EiWizard.MethodsSetupPage + + class MethodsSetupPage(widgets.ABWizardPage): + """Wizard page to choose methods setup options""" + title = "Methods setup" + subtitle = "Choose whether to import methods from ecoinvent or from file" + buttonLayout = ["Stretch", "CommitButton"] + + def __init__(self, parent=None): + super().__init__(parent) + + self.methods_choice = widgets.ABRadioButtonCollapser(self) + self.methods_choice.buttonClicked.connect(lambda: self.completeChanged.emit()) + + self.methods_choice.addOption( + name="remote", + label="Download methods from ecoinvent", + w=QtWidgets.QWidget() + ) + + self.methods_choice.addOption( + name="local", + label="Import methods from file", + w=widgets.ABFileSelector(filter="*.xlsx") + ) + + self.methods_choice.addOption( + name="skip", + label="Don't import methods", + w=QtWidgets.QWidget() + ) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.methods_choice) + + self.setLayout(layout) + + def finalize(self, context: dict): + """Store the selected methods option in the context""" + if self.methods_choice.currentOption() == "remote": + file = ei.get_excel_lcia_file_for_version(context["release"], context["version"]) + context["methods_filepath"] = str(file) + if self.methods_choice.currentOption() == "local": + context["methods_filepath"] = self.methods_choice.view("local").text() + + def isComplete(self): + """Check if a methods option has been selected""" + return self.methods_choice.currentOption() is not None + + def nextPage(self): + """Proceed to the appropriate next page based on the methods choice""" + if self.methods_choice.currentOption() == "remote" or self.methods_choice.currentOption() == "local": + return EiWizard.MethodsInstallPage + else: + return EiWizard.EcoinventSetupPage + + class MethodsInstallPage(widgets.ABThreadedWizardPage): + """Wizard page to install the selected methods""" + title = "Installing methods" + subtitle = "Installing selected methods and linking to the biosphere" + buttonLayout = ["Stretch", "NextButton"] + + class Thread(threading.ABThread): + """Thread to handle the methods installation process""" + def run_safely(self, methods_filepath: str, biosphere_name: str): + """Install the methods and link to the biosphere""" + importer = EcoinventLCIAImporter.setup_with_ei_excel(methods_filepath) + importer.set_biosphere(biosphere_name) + importer.apply_strategies() + + signals.method.blockSignals(True) + signals.meta.blockSignals(True) + + old = bd.methods.deserialize() + importer.write_methods(overwrite=True) + + signals.method.blockSignals(False) + signals.meta.blockSignals(False) + + signals.meta.methods_changed.emit(deepcopy(bd.methods.data), old) + + def initializePage(self, context: dict): + """Start the methods installation thread""" + self.thread.start(context["methods_filepath"], context["biosphere_name"]) + + def nextPage(self): + """Proceed to the EcoinventSetupPage""" + return EiWizard.EcoinventSetupPage + + class EcoinventSetupPage(widgets.ABWizardPage): + """Wizard page to set up the ecoinvent database""" + title = "Ecoinvent setup" + subtitle = "Choose name for ecoinvent database" + buttonLayout = ["Stretch", "CancelButton", "CommitButton"] + + def __init__(self, parent=None): + super().__init__(parent) + self.database_name = widgets.DatabaseNameEdit(database_preset="ecoinvent") + self.database_name.textChanged.connect(self.completeChanged) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.database_name) + self.setLayout(layout) + + def isComplete(self): + """Check if a database name has been entered""" + return bool(self.database_name.text()) + + def initializePage(self, context: dict): + """Start the biosphere installation thread""" + if "version" in context and "model" in context: + self.database_name.setText(f"ecoinvent-{context['version']}-{context['model']}") + + def finalize(self, context: dict): + """Store the database name in the context""" + context["database_name"] = self.database_name.text() + + def nextPage(self): + """Proceed to the EcoinventInstallPage""" + return EiWizard.EcoinventInstallPage + + class EcoinventInstallPage(widgets.ABThreadedWizardPage): + """Wizard page to install the ecoinvent database""" + title = "Installing ecoinvent" + subtitle = "Installing ecoinvent database into the project" + buttonLayout = ["Stretch", "FinishButton"] + + class Thread(threading.ABThread): + """Thread to handle the ecoinvent installation process""" + def run_safely(self, ei_filepath: str, database_name: str, biosphere_name: str): + """Install the ecoinvent database""" + importer = Ecoinvent7zImporter(ei_filepath) + importer.install_ecoinvent(database_name, biosphere_name) + + # Run migrations after installation + if len(bi.migrations) == 0: + ab_create_core_migrations() + + def initializePage(self, context: dict): + """Start the ecoinvent installation thread""" + self.thread.start( + context["ei_filepath"], + context["database_name"], + context["biosphere_name"] + ) + + pages = [ + RemoteOrLocalPage, LocalSelectPage, LoginPage, EcoinventVersionPage, EcoinventDownloadPage, BiosphereSetupPage, + BiosphereInstallPage, MethodsSetupPage, MethodsInstallPage, EcoinventSetupPage, EcoinventInstallPage + ] diff --git a/activity_browser/app/actions/database/database_importer_bw2package.py b/activity_browser/app/actions/database/database_importer_bw2package.py new file mode 100644 index 000000000..d42cbaa28 --- /dev/null +++ b/activity_browser/app/actions/database/database_importer_bw2package.py @@ -0,0 +1,90 @@ +import os +from loguru import logger + +from qtpy import QtWidgets + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui import icons, widgets +from activity_browser.bwutils.importers import ABPackage +from activity_browser.ui.core import threading + + + + +class DatabaseImporterBW2Package(ABAction): + """ABAction to open the DatabaseImportWizard""" + + icon = icons.qicons.import_db + text = "Import database from .bw2package" + tool_tip = "Import database from .bw2package" + + @classmethod + @exception_dialogs + def run(cls): + # get the path from the user + path, _ = QtWidgets.QFileDialog.getOpenFileName( + parent=app.main_window, + caption='Choose .bw2package to import', + filter='Brightway2 Database Package (*.bw2package);; All files (*.*)' + ) + if not path: + return + + # a bit of pathname magic to get a suggested database name + context = { + "path": path, + "database_name": os.path.basename(path).split('.bw2package')[0] + } + + # show the import setup dialog + import_dialog = ImportSetup(parent=app.main_window, title="Import Database", context=context) + import_dialog.exec_() + + +class ImportSetup(widgets.ABWizard): + class DatabaseName(widgets.ABWizardPage): + title = "Database Name" + subtitle = "Enter the name of the database you wish to create" + + def __init__(self, parent=None): + super().__init__(parent) + self.db_name_edit = widgets.DatabaseNameEdit( + label="Set database name:", + database_preset="", + ) + self.db_name_edit.textChanged.connect(self.completeChanged) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.db_name_edit) + self.setLayout(layout) + + def isComplete(self): + return bool(self.db_name_edit.text()) + + def initializePage(self, context: dict): + self.db_name_edit.setText(context["database_name"]) + + def finalize(self, context: dict): + context["database_name"] = self.db_name_edit.text() + + def nextPage(self): + return ImportSetup.InstallPage + + class InstallPage(widgets.ABThreadedWizardPage): + """Wizard page to install the selected bw2package""" + title = "Importing Database" + subtitle = "Importing database from .bw2package file" + + class Thread(threading.ABThread): + """Thread to handle the install process""" + def run_safely(self, path: str, db_name: str): + """Download the ecoinvent release""" + ABPackage.import_file(path, rename=db_name) + + def initializePage(self, context: dict): + """Start the download thread""" + self.thread.start(context["path"], context["database_name"]) + + pages = [DatabaseName, InstallPage] + diff --git a/activity_browser/app/actions/database/database_importer_excel.py b/activity_browser/app/actions/database/database_importer_excel.py new file mode 100644 index 000000000..6604add2b --- /dev/null +++ b/activity_browser/app/actions/database/database_importer_excel.py @@ -0,0 +1,211 @@ +from loguru import logger + +from qtpy import QtWidgets +from qtpy.QtCore import Signal, SignalInstance + +import bw2data as bd + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui import widgets +from activity_browser.bwutils.importers import ABExcelImporter +from activity_browser.ui.core import threading + + + + +class DatabaseImporterExcel(ABAction): + """ABAction to open the DatabaseImportWizard""" + + text = "Import database from brightway excel format" + tool_tip = "Import database from brightway excel format" + + @classmethod + @exception_dialogs + def run(cls): + # get the path from the user + path, _ = QtWidgets.QFileDialog.getOpenFileName( + parent=app.main_window, + caption='Choose brightway excel database to import', + filter='excel spreadsheet (*.xlsx);; All files (*.*)' + ) + if not path: + return + + import_setup = ImportSetup(title="Import from Excel", context={"path": path}) + import_setup.exec_() + + +class ImportSetup(widgets.ABWizard): + + def customButtonOne(self): + def callback(): + importer : ABExcelImporter = self.context.get("importer") + if not importer: + return + dialog = app.dialogs.ImportPreviewDialog(importer, parent=app.main_window) + dialog.exec_() + return "Data", callback + + class ExtractPage(widgets.ABThreadedWizardPage): + title = "Extracting Database" + subtitle = "Extracting database from excel file" + buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "NextButton"] + customButton1Text = "Show extracted data" + + class Thread(threading.ABThread): + loaded: SignalInstance = Signal(object) + + def run_safely(self, path: str): + importer = ABExcelImporter(path) + importer.apply_basic_strategies() + self.loaded.emit(importer) + + def initializePage(self, context: dict): + """Start the download thread""" + self.thread.start(context["path"]) + self.thread.loaded.connect(self.thread_finished) + + button = self.wizard().button(QtWidgets.QWizard.CustomButton1) + button.setEnabled(False) + + def thread_finished(self, importer: ABExcelImporter): + logger.debug("Extraction thread finished") + self.context()["importer"] = importer + + button = self.wizard().button(QtWidgets.QWizard.CustomButton1) + button.setEnabled(True) + + def nextPage(self) -> type[QtWidgets.QWizardPage] | None: + return ImportSetup.DatabaseName + + class DatabaseName(widgets.ABWizardPage): + title = "Database Name" + subtitle = "Enter the name of the database you wish to create" + buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "NextButton"] + + def __init__(self, parent=None): + super().__init__(parent) + self.db_name_edit = widgets.DatabaseNameEdit( + label="Set database name:", + database_preset="", + ) + self.db_name_edit.textChanged.connect(self.completeChanged) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.db_name_edit) + self.setLayout(layout) + + def isComplete(self): + return bool(self.db_name_edit.text()) + + def initializePage(self, context: dict): + self.db_name_edit.setText(context["importer"].db_name) + self.wizard().setButtonText(QtWidgets.QWizard.WizardButton.NextButton, "Apply") + + def finalize(self, context: dict): + importer = context["importer"] + importer.apply_db_name(self.db_name_edit.text()) + + context["database_name"] = self.db_name_edit.text() + + def nextPage(self): + return ImportSetup.DatabaseLink + + class DatabaseLink(widgets.ABWizardPage): + title = "Link Databases" + subtitle = "Link the imported database to existing databases" + buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "NextButton"] + + def __init__(self, parent=None): + super().__init__(parent) + layout = QtWidgets.QGridLayout(self) + self.setLayout(layout) + self.link_dict_edit = {} + + def isComplete(self): + return True + + def initializePage(self, context: dict): + # fetch the unlinked databases from the importer + importer = context["importer"] + link_dbs = set([exc["database"] for exc in importer.unlinked]) + layout = self.layout() + + for i, db in enumerate(link_dbs): + if db == importer.db_name: + continue + + layout.addWidget(QtWidgets.QLabel(db), i, 0) + + drop_down = QtWidgets.QComboBox(self) + drop_down.addItems(sorted(bd.databases)) + + if db in bd.databases: + drop_down.setCurrentText(db) + + layout.addWidget(drop_down, i, 1) + + self.link_dict_edit[db] = drop_down + + def finalize(self, context: dict): + importer = context["importer"] + importer.apply_linking({k: v.currentText() for k, v in self.link_dict_edit.items()}) + + context["linking_dict"] = {k: v.currentText() for k, v in self.link_dict_edit.items()} + + def nextPage(self): + return ImportSetup.ConfirmPage + + class ConfirmPage(widgets.ABWizardPage): + title = "Database Overview" + subtitle = "Confirming and installing the database" + buttonLayout = ["CustomButton1", "Stretch", "CancelButton", "CommitButton"] + + def __init__(self, parent=None): + super().__init__(parent) + layout = QtWidgets.QGridLayout(self) + self.setLayout(layout) + + def isComplete(self): + return True + + def initializePage(self, context: dict): + importer = context["importer"] + layout = self.layout() + row = 0 + for key, value in { + "Database Name": importer.db_name, + "Number of Activities": len(importer.data), + "Number of Exchanges": sum(len(act.get("exchanges", [])) for act in importer.data), + "Number of Unlinked Exchanges": len(list(importer.unlinked)), + }.items(): + layout.addWidget(QtWidgets.QLabel(f"{key}:"), row, 0) + layout.addWidget(QtWidgets.QLabel(str(value)), row, 1) + row += 1 + + def nextPage(self): + return ImportSetup.InstallPage + + class InstallPage(widgets.ABThreadedWizardPage): + title = "Importing Database" + subtitle = "Importing database from .xlsx file" + + class Thread(threading.ABThread): + """Thread to handle the install process""" + + def run_safely(self, importer: ABExcelImporter, database_name: str, linking_dict: dict): + """Download the ecoinvent release""" + importer.write_database() + + def initializePage(self, context: dict): + """Start the download thread""" + importer = context["importer"] + database_name = context["database_name"] + linking_dict = context.get("linking_dict", {}) + + self.thread.start(importer, database_name, linking_dict) + + pages = [ExtractPage, DatabaseName, DatabaseLink, ConfirmPage, InstallPage] + + diff --git a/activity_browser/app/actions/database/database_new.py b/activity_browser/app/actions/database/database_new.py new file mode 100644 index 000000000..2933e9d45 --- /dev/null +++ b/activity_browser/app/actions/database/database_new.py @@ -0,0 +1,111 @@ +from qtpy import QtWidgets, QtCore + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + +from .database_open import DatabaseOpen + + +class DatabaseNew(ABAction): + """ + Executes the process of creating a new database. + + This method retrieves the database name and backend type from the `NewDatabaseDialog`. + It validates the input to ensure the name is not empty and does not already exist. + If the input is valid, it creates and registers a new database, then opens it. + + Steps: + - Open the `NewDatabaseDialog` to get the database name and backend type. + - Return if the dialog is canceled or the name is empty. + - Check if the database name already exists and show an error message if it does. + - Create a new database with the specified name and backend type. + - Register the database with default settings (not searchable, not read-only). + - Open the newly created database. + + Raises: + None + """ + icon = qicons.add + text = "New database..." + tool_tip = "Make a new database" + + @staticmethod + @exception_dialogs + def run(): + name, backend, ok = NewDatabaseDialog.get_new_database_data() + + if not ok or not name: + return + + if name in bd.databases: + QtWidgets.QMessageBox.information( + app.main_window, + "Not possible", + "A database with this name already exists.", + ) + return + + db = bd.Database(name, backend if backend else "functional_sqlite") + db.register(searchable=False, read_only=False) + + DatabaseOpen.run([name]) + + +class NewDatabaseDialog(QtWidgets.QDialog): + """ + A dialog for creating a new database. + """ + + def __init__(self, window_title="New Database", backend="functional_sqlite", parent=None): + super().__init__(parent) + self.setWindowTitle(window_title) + self.setModal(True) + + self.name_input = QtWidgets.QLineEdit(self) + self.name_input.setPlaceholderText("Enter database name") + self.name_input.textChanged.connect(self.validate) + + self.backend_dropdown = QtWidgets.QComboBox(self) + self.backend_dropdown.addItems(["functional_sqlite", "sqlite"]) + self.backend_dropdown.setCurrentText(backend) + + self.create_button = QtWidgets.QPushButton("Create", self) + self.create_button.setDisabled(True) + self.create_button.clicked.connect(self.accept) + + self.build_layout() + + @classmethod + def get_new_database_data(cls, window_title="New Database", backend="functional_sqlite") -> tuple[str, str, bool]: + """ + Opens a dialog to collect data for creating a new database. + + Returns: + tuple[str, str, bool]: A tuple containing: + - The name of the new database (str). + - The selected backend type (str). + - A boolean indicating whether the dialog was accepted (True) or canceled (False). + """ + dialog = cls(window_title, backend, app.main_window) + result = dialog.exec_() + + return dialog.name_input.text(), dialog.backend_dropdown.currentText(), result == QtWidgets.QDialog.Accepted + + + def build_layout(self): + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.name_input) + layout.addWidget(QtWidgets.QLabel("Select backend:", self)) + layout.addWidget(self.backend_dropdown) + layout.addWidget(self.create_button, alignment=QtCore.Qt.AlignRight) + self.setLayout(layout) + + def validate(self, text): + if text in bd.databases or not text: + self.create_button.setDisabled(True) + else: + self.create_button.setDisabled(False) + + diff --git a/activity_browser/app/actions/database/database_open.py b/activity_browser/app/actions/database/database_open.py new file mode 100644 index 000000000..3a8ef5623 --- /dev/null +++ b/activity_browser/app/actions/database/database_open.py @@ -0,0 +1,64 @@ +from qtpy.QtCore import Qt, QEventLoop + +from activity_browser import app +from activity_browser.ui import widgets +from activity_browser.app.actions.base import ABAction, exception_dialogs + + + + +class DatabaseOpen(ABAction): + text = "Open Database" + + @staticmethod + @exception_dialogs + def run(database_names: list[str]): + from activity_browser.app import panes + + sibling = DatabaseOpen.find_sibling() + + for db_name in database_names: + db_pane = panes.DatabaseProductsPane(app.main_window, db_name) + dock_widget = db_pane.getDockWidget(app.main_window) + dock_widget.resize(dock_widget.width(), app.main_window.height() // 2) + + app.main_window.addDockWidget(DatabaseOpen.get_area(), dock_widget) + + if sibling: + app.main_window.tabifyDockWidget(sibling, dock_widget) + + app.application.thread().eventDispatcher().processEvents(QEventLoop.ProcessEventsFlags.AllEvents) + dock_widget.raise_() + dock_widget.show() + else: + dock_widget.show() + app.main_window.resizeDocks( + [dock_widget], + [1000], + Qt.Vertical + ) + + @staticmethod + def find_sibling(): + """ + Find the dockwidget location where the database pane should be opened. + """ + from activity_browser.app import panes + + all_dws = app.main_window.findChildren(widgets.ABDockWidget) + databases_dw = app.main_window.findChild(widgets.ABDockWidget, "dockwidget-databases_pane") + + products_dws = [w for w in all_dws if + isinstance(w.widget(), panes.DatabaseProductsPane) and + app.main_window.dockWidgetArea(w) == app.main_window.dockWidgetArea(databases_dw) and + not w.visibleRegion().isNull() + ] + return products_dws[0] if products_dws else None + + @staticmethod + def get_area(): + """ + Find the dockwidget location where the database pane should be opened. + """ + databases_dw = app.main_window.findChild(widgets.ABDockWidget, "dockwidget-databases_pane") + return app.main_window.dockWidgetArea(databases_dw) diff --git a/activity_browser/app/actions/database/database_process.py b/activity_browser/app/actions/database/database_process.py new file mode 100644 index 000000000..0f5245829 --- /dev/null +++ b/activity_browser/app/actions/database/database_process.py @@ -0,0 +1,19 @@ +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + + +class DatabaseProcess(ABAction): + """ + ... + """ + + icon = qicons.process + text = "Process database" + tool_tip = "Process database into datapackages" + + @staticmethod + @exception_dialogs + def run(db_name: str): + db = bd.Database(db_name) + db.process() diff --git a/activity_browser/app/actions/database/database_relink.py b/activity_browser/app/actions/database/database_relink.py new file mode 100644 index 000000000..ba6325b92 --- /dev/null +++ b/activity_browser/app/actions/database/database_relink.py @@ -0,0 +1,273 @@ +from qtpy import QtCore, QtWidgets + +import bw2data as bd +from bw2data.backends import ExchangeDataset, sqlite3_lci_db + +from activity_browser.app import application, metadata +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + + +class DatabaseRelink(ABAction): + """ + ABAction to relink the dependencies of a database. + """ + + icon = qicons.edit + text = "Relink the database" + tool_tip = "Relink the dependencies of this database" + + @staticmethod + @exception_dialogs + def run(db_name: str): + db_name = db_name + # get brightway database object + db = bd.Database(db_name) + + depends = ExchangeDataset.select(ExchangeDataset.input_database).where(ExchangeDataset.output_database == db_name) + depends = set([d.input_database for d in depends if d.input_database != db_name]) + + # find the dependencies of the database and construct a list of suitable candidates + options = [(depend, list(bd.databases)) for depend in depends] + + # construct a dialog in which the user chan choose which depending database to connect to which candidate + dialog = DatabaseLinkingDialog.relink_sqlite( + db_name, options, application.main_window + ) + + # return if the user cancels + if dialog.exec_() != DatabaseLinkingDialog.Accepted: + return + + linking_dict = {k: v for k, v in dialog.links.items() if k != v} + + if not linking_dict: + return + + relink_keys = DatabaseRelink.get_input_keys(db_name, list(linking_dict.keys())) + datasets = metadata.get_metadata(relink_keys, ["name", "product", "unit", "categories", "location"]) + + relink_key_map = {} + for ds in datasets.itertuples(): + key = ds.Index + database = linking_dict.get(key[0]) + match = metadata.match( + name=ds.name, + product=ds.product, + unit=ds.unit, + categories=ds.categories, + location=ds.location, + database=database, + ) + + if not len(match) == 1: + raise Exception(f"Could not uniquely relink exchange from {key} in database {database}") + + relink_key_map[key] = match.index[0] + + DatabaseRelink.set_input_keys(db_name, relink_key_map) + + QtWidgets.QMessageBox.information( + application.main_window, + "Database relinked", + f"Successfully relinked database '{db_name}'." + ) + + @staticmethod + def get_input_keys(output_db: str, db_list: list[str]) -> list[tuple[str, str]]: + return list( + ( + ExchangeDataset + .select(ExchangeDataset.input_database, ExchangeDataset.input_code) + .where( + (ExchangeDataset.output_database == output_db) & + (ExchangeDataset.input_database << db_list) + ) + ).tuples() + ) + + @staticmethod + def set_input_keys(output_db: str, key_map: dict[tuple[str, str], tuple[str, str]]) -> None: + with sqlite3_lci_db.db.atomic(): + for old_key, new_key in key_map.items(): + ExchangeDataset.update( + input_database=new_key[0], + input_code=new_key[1] + ).where( + (ExchangeDataset.output_database == output_db) & + (ExchangeDataset.input_database == old_key[0]) & + (ExchangeDataset.input_code == old_key[1]) + ).execute() + + +class DatabaseLinkingDialog(QtWidgets.QDialog): + """Display all of the possible links in a single dialog for the user. + + Allow users to select alternate database links.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Database linking") + + self.db_label = QtWidgets.QLabel() + self.label_choices = [] + self.grid_box = QtWidgets.QGroupBox("Database links:") + self.grid = QtWidgets.QGridLayout() + self.grid_box.setLayout(self.grid) + + self.buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, + ) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.db_label) + layout.addWidget(self.grid_box) + layout.addWidget(self.buttons) + self.setLayout(layout) + + @property + def relink(self) -> dict: + """Returns a dictionary of str -> str key/values, showing which keys + should be linked to which values. + + Only returns key/value pairs if they differ. + """ + return { + label.text(): combo.currentText() + for label, combo in self.label_choices + if label.text() != combo.currentText() + } + + @property + def links(self) -> dict: + """Returns a dictionary of str -> str key/values, showing which keys + should be linked to which values. + """ + return { + label.text(): combo.currentText() for label, combo in self.label_choices + } + + @classmethod + def construct_dialog( + cls, + label: str, + options: list, + parent: QtWidgets.QWidget = None, + ) -> "DatabaseLinkingDialog": + obj = cls(parent) + obj.db_label.setText(label) + # Start at 1 because row 0 is taken up by the db_label + for i, item in enumerate(options): + label = QtWidgets.QLabel(item[0]) + combo = QtWidgets.QComboBox() + combo.addItems(item[1]) + combo.setCurrentText(item[0]) + obj.label_choices.append((label, combo)) + obj.grid.addWidget(label, i, 0, 1, 2) + obj.grid.addWidget(combo, i, 2, 1, 2) + obj.updateGeometry() + return obj + + @classmethod + def relink_sqlite( + cls, db: str, options: list, parent=None + ) -> "DatabaseLinkingDialog": + label = "Relinking exchanges from database '{}'.".format(db) + return cls.construct_dialog(label, options, parent) + + @classmethod + def relink_bw2package( + cls, options: list, parent=None + ) -> "DatabaseLinkingDialog": + label = ( + "Some database(s) could not be found in the current project," + " attempt to relink the exchanges to a different database?" + ) + return cls.construct_dialog(label, options, parent) + + @classmethod + def relink_excel( + cls, options: list, parent=None + ) -> "DatabaseLinkingDialog": + label = "Customize database links for exchanges in the imported database." + return cls.construct_dialog(label, options, parent) + + +class DatabaseLinkingResultsDialog(QtWidgets.QDialog): + """To be used when relinking a database, this dialog will pop up if + some of the exchanges in the database fail to be linked to the new + database. + Up to five of the unlinked activities are printed on the screen, + + """ + + def __init__(self, parent=None): + super().__init__(parent) + + self.setWindowTitle("Relinking database results") + + button = QtWidgets.QDialogButtonBox.Ok + self.buttonBox = QtWidgets.QDialogButtonBox(button) + self.buttonBox.accepted.connect(self.accept) + self.databases_relinked = QtWidgets.QVBoxLayout() + + self.activityToOpen = set() + + self.exchangesUnlinked = QtWidgets.QVBoxLayout() + + self.layout = QtWidgets.QVBoxLayout() + self.layout.addLayout(self.databases_relinked) + self.layout.addLayout(self.exchangesUnlinked) + self.layout.addWidget(self.buttonBox) + self.setLayout(self.layout) + + @classmethod + def construct_results_dialog( + cls, + parent: QtWidgets.QWidget = None, + link_results: dict = None, + unlinked_exchanges: dict = None, + ) -> "DatabaseLinkingResultsDialog": + from activity_browser import app + + obj = cls(parent) + for k, results in link_results.items(): + obj.databases_relinked.addWidget( + QtWidgets.QLabel(f"{k} = {results[1]} successfully linked") + ) + obj.databases_relinked.addWidget( + QtWidgets.QLabel(f"{k} = {results[0]} flows failed to link") + ) + + obj.exchangesUnlinked.addWidget( + QtWidgets.QLabel("Up to 5 unlinked exchanges (click to open)") + ) + for act, key in unlinked_exchanges.items(): + button = QtWidgets.QPushButton(act.as_dict()["name"]) + button.clicked.connect( + lambda: app.actions.ActivityOpen.run([act.key]) + ) + obj.exchangesUnlinked.addWidget(button) + obj.updateGeometry() + + return obj + + @classmethod + def present_relinking_results( + cls, + parent: QtWidgets.QWidget = None, + link_results: dict = None, + unlinked_exchanges: dict = None, + ) -> "DatabaseLinkingResultsDialog": + return cls.construct_results_dialog(parent, link_results, unlinked_exchanges) + + def select_activity_to_open(self, actvty: tuple) -> None: + if actvty in self.activityToOpen: + self.activityToOpen.discard(actvty) + self.activityToOpen.add(actvty) + + def open_activity(self): + return self.activityToOpen + diff --git a/activity_browser/app/actions/database/database_set_readonly.py b/activity_browser/app/actions/database/database_set_readonly.py new file mode 100644 index 000000000..b54696285 --- /dev/null +++ b/activity_browser/app/actions/database/database_set_readonly.py @@ -0,0 +1,33 @@ +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd + + +class DatabaseSetReadonly(ABAction): + """ + ABAction to set a database as read-only. + + This action allows marking a database as read-only by updating its metadata. + It can also be used to remove the read-only status by setting the `read_only` flag to `False`. + + Attributes: + text (str): The display text for this action. + tool_tip (str): The tooltip text for this action. + """ + + text = "Set database read-only" + tool_tip = "Set this database to read-only" + + @staticmethod + @exception_dialogs + def run(db_name: str, read_only=True): + """ + Execute the action to set the read-only status of a database. + + This method updates the `read_only` flag in the metadata of the specified database. + + Args: + db_name (str): The name of the database to update. + read_only (bool, optional): The desired read-only status. Defaults to True. + """ + bd.databases[db_name]["read_only"] = read_only + bd.databases.flush() diff --git a/activity_browser/app/actions/exchange/exchange_copy_sdf.py b/activity_browser/app/actions/exchange/exchange_copy_sdf.py new file mode 100644 index 000000000..f7d2c0b8b --- /dev/null +++ b/activity_browser/app/actions/exchange/exchange_copy_sdf.py @@ -0,0 +1,23 @@ +from typing import Any, List + +import pandas as pd + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils import commontasks +from activity_browser.ui.icons import qicons + + +class ExchangeCopySDF(ABAction): + """ + ABAction to copy the exchange information in SDF format to the clipboard. + """ + + icon = qicons.superstructure + text = "Exchanges for scenario difference file" + + @staticmethod + @exception_dialogs + def run(exchanges: List[Any]): + data = commontasks.get_exchanges_in_scenario_difference_file_notation(exchanges) + df = pd.DataFrame(data) + df.to_clipboard(excel=True, index=False) diff --git a/activity_browser/app/actions/exchange/exchange_delete.py b/activity_browser/app/actions/exchange/exchange_delete.py new file mode 100644 index 000000000..2a22a2f02 --- /dev/null +++ b/activity_browser/app/actions/exchange/exchange_delete.py @@ -0,0 +1,19 @@ +from typing import Any, List + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + + +class ExchangeDelete(ABAction): + """ + ABAction to delete one or more exchanges from an activity. + """ + + icon = qicons.delete + text = "Delete exchange(s)" + + @staticmethod + @exception_dialogs + def run(exchanges: List[Any]): + for exchange in exchanges: + exchange.delete() diff --git a/activity_browser/app/actions/exchange/exchange_formula_remove.py b/activity_browser/app/actions/exchange/exchange_formula_remove.py new file mode 100644 index 000000000..4afdbcbb7 --- /dev/null +++ b/activity_browser/app/actions/exchange/exchange_formula_remove.py @@ -0,0 +1,27 @@ +from typing import Any, List + +from bw2data.parameters import ParameterizedExchange + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + + +class ExchangeFormulaRemove(ABAction): + """ + ABAction to clear the formula's of one or more exchanges. + """ + + icon = qicons.delete + text = "Clear formula(s)" + + @staticmethod + @exception_dialogs + def run(exchanges: List[Any]): + for exchange in exchanges: + if "formula" not in exchange: + return + + del exchange["formula"] + exchange.save() + + ParameterizedExchange.delete().where(ParameterizedExchange.exchange == exchange._document.id).execute() diff --git a/activity_browser/app/actions/exchange/exchange_modify.py b/activity_browser/app/actions/exchange/exchange_modify.py new file mode 100644 index 000000000..ae52995f4 --- /dev/null +++ b/activity_browser/app/actions/exchange/exchange_modify.py @@ -0,0 +1,57 @@ +from bw2data.proxies import ExchangeProxyBase + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from bw2data.parameters import ActivityParameter +from activity_browser.ui.icons import qicons + +from ..parameter.parameter_new_automatic import ParameterNewAutomatic +from .exchange_formula_remove import ExchangeFormulaRemove + + + + +class ExchangeModify(ABAction): + """ + ABAction to modify an exchange with the supplied data. + """ + + icon = qicons.delete + text = "Modify exchange" + + @classmethod + @exception_dialogs + def run(cls, exchange: ExchangeProxyBase, data: dict): + # remove the formula if it is an empty string + if "formula" in exchange and data.get("formula") == "": + del data["formula"] + ExchangeFormulaRemove.run([exchange]) + + for key, value in data.items(): + exchange[key] = value + + exchange.save() + + if "formula" in data: + cls.parameterize_exchanges(exchange.output.key) + + @staticmethod + def parameterize_exchanges(key: tuple) -> None: + """Used whenever a formula is set on an exchange in an activity. + + If no `ActivityParameter` exists for the key, generate one immediately + """ + act = bd.get_activity(key) + query = (ActivityParameter.database == key[0]) & ( + ActivityParameter.code == key[1] + ) + + if not ActivityParameter.select().where(query).count(): + ParameterNewAutomatic.run([key]) + + group = ActivityParameter.get(query).group + + with bd.parameters.db.atomic(): + bd.parameters.remove_exchanges_from_group(group, act) + bd.parameters.add_exchanges_to_group(group, act) + ActivityParameter.recalculate_exchanges(group) diff --git a/activity_browser/app/actions/exchange/exchange_new.py b/activity_browser/app/actions/exchange/exchange_new.py new file mode 100644 index 000000000..454ce537a --- /dev/null +++ b/activity_browser/app/actions/exchange/exchange_new.py @@ -0,0 +1,23 @@ +from typing import List + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils import commontasks +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + + +class ExchangeNew(ABAction): + """ + ABAction to create a new exchange for an activity. + """ + + icon = qicons.add + text = "Add exchanges" + + @staticmethod + @exception_dialogs + def run(from_keys: List[tuple], to_key: tuple, type: str, amount: float = 1): + to_activity = bd.get_activity(to_key) + for from_key in from_keys: + exchange = to_activity.new_exchange(input=from_key, type=type, amount=amount) + exchange.save() diff --git a/activity_browser/app/actions/exchange/exchange_sdf_to_clipboard.py b/activity_browser/app/actions/exchange/exchange_sdf_to_clipboard.py new file mode 100644 index 000000000..b775d5aad --- /dev/null +++ b/activity_browser/app/actions/exchange/exchange_sdf_to_clipboard.py @@ -0,0 +1,34 @@ +from typing import List + +import bw2data as bd +import bw_functional as bf + +from activity_browser.bwutils.commontasks import refresh_edge, exchanges_to_sdf +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + + +class ExchangeSDFToClipboard(ABAction): + """ + ABAction to open one or more supplied activities in an activity tab by employing signals. + + TODO: move away from using signals like this. Probably add a method to the MainWindow to add a panel instead. + """ + + icon = qicons.superstructure + text = "SDF to clipboard" + + @staticmethod + @exception_dialogs + def run(exchanges: List[int | bd.Edge]): + exchanges = [refresh_edge(edge) for edge in exchanges] + + virtual_exchanges = [] + for exchange in exchanges: + if isinstance(exchange, bf.MFExchange): + virtual_exchanges += exchange.virtual_edges + else: + virtual_exchanges.append(exchange.as_dict()) + + df = exchanges_to_sdf(virtual_exchanges) + df.to_clipboard(excel=True, index=False) diff --git a/activity_browser/app/actions/exchange/exchange_uncertainty_modify.py b/activity_browser/app/actions/exchange/exchange_uncertainty_modify.py new file mode 100644 index 000000000..cc105def1 --- /dev/null +++ b/activity_browser/app/actions/exchange/exchange_uncertainty_modify.py @@ -0,0 +1,34 @@ +from typing import Any, List + +import bw2data as bd + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons +from activity_browser.ui.dialogs import UncertaintyDialog + + +class ExchangeUncertaintyModify(ABAction): + """ + ABAction to open the UncertaintyWizard for an exchange + """ + + icon = qicons.edit + text = "Modify uncertainty" + + @staticmethod + @exception_dialogs + def run(exchanges: List[bd.Edge]): + + ok, uc_dict = UncertaintyDialog.get_uncertainty_dict( + parent=app.main_window, + initial=exchanges[0].uncertainty, + ) + + if not ok: + return + + for exchange in exchanges: + for key, value in uc_dict.items(): + exchange[key] = value + exchange.save() diff --git a/activity_browser/app/actions/exchange/exchange_uncertainty_remove.py b/activity_browser/app/actions/exchange/exchange_uncertainty_remove.py new file mode 100644 index 000000000..22ecbd12c --- /dev/null +++ b/activity_browser/app/actions/exchange/exchange_uncertainty_remove.py @@ -0,0 +1,23 @@ +from typing import Any, List + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils import uncertainty +from activity_browser.ui.icons import qicons + + +class ExchangeUncertaintyRemove(ABAction): + """ + ABAction to clear the uncertainty of one or multiple exchanges. + """ + + icon = qicons.delete + text = "Remove uncertainty/-ies" + + @staticmethod + @exception_dialogs + def run(exchanges: List[Any]): + for exchange in exchanges: + for key, value in uncertainty.EMPTY_UNCERTAINTY.items(): + exchange[key] = value + + exchange.save() diff --git a/activity_browser/app/actions/metadatastore_cache_clear.py b/activity_browser/app/actions/metadatastore_cache_clear.py new file mode 100644 index 000000000..30fd619c1 --- /dev/null +++ b/activity_browser/app/actions/metadatastore_cache_clear.py @@ -0,0 +1,20 @@ +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + +import bw2data as bd + +from .project.project_switch import ProjectSwitch + + +class MetaDataStoreCacheClear(ABAction): + + icon = qicons.right + text = "Clear Metadata Store Cache" + tool_tip = "Clear the Metadata Store cache and reload the current project" + + @staticmethod + @exception_dialogs + def run(): + app.metadata.clear_cache() + ProjectSwitch.run(bd.projects.current, reload=True) diff --git a/activity_browser/app/actions/metadatastore_open.py b/activity_browser/app/actions/metadatastore_open.py new file mode 100644 index 000000000..51d6b93ca --- /dev/null +++ b/activity_browser/app/actions/metadatastore_open.py @@ -0,0 +1,21 @@ +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons +from activity_browser.ui.core.application import global_shortcut + + + + +class MetaDataStoreOpen(ABAction): + + icon = qicons.right + text = "Open activity / activities" + + @staticmethod + @global_shortcut("Ctrl+Shift+M") + @exception_dialogs + def run(): + from activity_browser.app import pages + page = pages.MetaDataStorePage() + central = app.main_window.centralWidget() + central.addToGroup("DEBUG", page) diff --git a/activity_browser/app/actions/method/cf_amount_modify.py b/activity_browser/app/actions/method/cf_amount_modify.py new file mode 100644 index 000000000..2c5b1cb22 --- /dev/null +++ b/activity_browser/app/actions/method/cf_amount_modify.py @@ -0,0 +1,29 @@ +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + + +class CFAmountModify(ABAction): + """ + ABAction to modify the amount of a characterization factor within a method. Updates the CF-Tuple's second value + directly if there's no uncertainty dict. Otherwise, changes the "amount" from the uncertainty dict. + """ + + icon = qicons.edit + text = "Modify amount" + + @staticmethod + @exception_dialogs + def run(method: tuple | bd.Method, key: int | tuple, amount: float): + if isinstance(method, bd.Method): + method = method.name + + method = bd.Method(method) + method_dict = {cf[0]: cf[1] for cf in method.load()} + + if isinstance(method_dict[key], dict): + method_dict[key]["amount"] = amount + else: + method_dict[key] = amount + + method.write(list(method_dict.items())) diff --git a/activity_browser/app/actions/method/cf_new.py b/activity_browser/app/actions/method/cf_new.py new file mode 100644 index 000000000..305062496 --- /dev/null +++ b/activity_browser/app/actions/method/cf_new.py @@ -0,0 +1,46 @@ +from typing import List + +from qtpy import QtWidgets + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + + +class CFNew(ABAction): + """ + ABAction to add a new characterization flow to a method through one or more elementary-flow keys. + """ + + icon = qicons.add + text = "New characterization factor" + + @staticmethod + @exception_dialogs + def run(method_name: tuple, keys: List[tuple]): + # load old cf's from the Method + method_dict = {cf[0]: cf[1] for cf in bd.Method(method_name).load()} + + # use only the keys that don't already exist within the method + unique_keys = [key for key in keys if key not in method_dict] + + # if there are non-unique keys warn the user that these won't be added + if len(unique_keys) < len(keys): + QtWidgets.QMessageBox.warning( + app.main_window, + "Duplicate characterization factors", + "One or more of these elementary flows already exist within this method. Duplicate flows will not be " + "added", + ) + + # return if there are no new keys + if not unique_keys: + return + + # add the new keys to the method dictionary + for key in unique_keys: + method_dict[key] = 0.0 + + # write the updated dict to the method + bd.Method(method_name).write(list(method_dict.items())) diff --git a/activity_browser/app/actions/method/cf_remove.py b/activity_browser/app/actions/method/cf_remove.py new file mode 100644 index 000000000..5b79b7a83 --- /dev/null +++ b/activity_browser/app/actions/method/cf_remove.py @@ -0,0 +1,42 @@ +from typing import List + +from qtpy import QtWidgets + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + + +class CFRemove(ABAction): + """ + ABAction to remove one or more Characterization Factors from a method. First ask for confirmation and return if the + user cancels. Otherwise instruct the ImpactCategoryController to remove the selected Characterization Factors. + """ + + icon = qicons.delete + text = "Remove CF('s)" + + @staticmethod + @exception_dialogs + def run(method_name: tuple, char_factors: List[tuple]): + # ask the user whether they are sure to delete the calculation setup + warning = QtWidgets.QMessageBox.warning( + app.main_window, + "Deleting Characterization Factors", + f"Are you sure you want to delete {len(char_factors)} CF('s)?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + + # return if the users cancels + if warning == QtWidgets.QMessageBox.No: + return + + method = bd.Method(method_name) + method_dict = {cf[0]: cf[1] for cf in method.load()} + + for cf in char_factors: + method_dict.pop(cf[0]) + + method.write(list(method_dict.items())) diff --git a/activity_browser/app/actions/method/cf_uncertainty_modify.py b/activity_browser/app/actions/method/cf_uncertainty_modify.py new file mode 100644 index 000000000..2670650bd --- /dev/null +++ b/activity_browser/app/actions/method/cf_uncertainty_modify.py @@ -0,0 +1,47 @@ +from functools import partial +from typing import List + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons +from activity_browser.ui.dialogs import UncertaintyDialog + + +class CFUncertaintyModify(ABAction): + """ + ABAction to launch the UncertaintyDialog for Characterization Factor and handles the output by writing the + uncertainty data using the ImpactCategoryController to the Characterization Factor in question. + """ + + icon = qicons.edit + text = "Modify uncertainty" + + @classmethod + @exception_dialogs + def run(cls, method_name: tuple, char_factors: List[tuple], uncertainty_dict: dict = None): + + if uncertainty_dict is None: + initial = char_factors[0][1] + initial = initial if isinstance(initial, dict) else {} + + ok, uncertainty_dict = UncertaintyDialog.get_uncertainty_dict( + parent=app.main_window, + initial=initial, + ) + + if not ok: + return + + method = bd.Method(method_name) + method_dict = {cf[0]: cf[1] for cf in method.load()} + + for cf in char_factors: + if isinstance(cf[1], dict): + cf[1].update(uncertainty_dict) + method_dict[cf[0]] = cf[1] + else: + uncertainty_dict["amount"] = cf[1] + method_dict[cf[0]] = uncertainty_dict + + method.write(list(method_dict.items())) diff --git a/activity_browser/app/actions/method/cf_uncertainty_remove.py b/activity_browser/app/actions/method/cf_uncertainty_remove.py new file mode 100644 index 000000000..c26a4ad6d --- /dev/null +++ b/activity_browser/app/actions/method/cf_uncertainty_remove.py @@ -0,0 +1,40 @@ +from typing import List + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + + +class CFUncertaintyRemove(ABAction): + """ + ABAction to remove the uncertainty from one or multiple Characterization Factors. + """ + + icon = qicons.clear + text = "Remove uncertainty" + + @staticmethod + @exception_dialogs + def run(method_name: tuple, char_factors: List[tuple]): + # create a list of CF's of which the uncertainty dict is removed + cleaned_cfs = [] + for cf in char_factors: + # if there's no uncertainty dict, we may continue + if not isinstance(cf[1], dict): + continue + + # else replace the uncertainty dict with the float found in the amount field of said dict + cleaned_cfs.append((cf[0], cf[1]["amount"])) + + # if the list is empty we can return + if not cleaned_cfs: + return + + # else write the cf's to the method + method = bd.Method(method_name) + method_dict = {cf[0]: cf[1] for cf in method.load()} + + for cf in cleaned_cfs: + method_dict[cf[0]] = cf[1] + + method.write(list(method_dict.items())) diff --git a/activity_browser/app/actions/method/importer/method_importer_bw2io.py b/activity_browser/app/actions/method/importer/method_importer_bw2io.py new file mode 100644 index 000000000..cc2fc9c29 --- /dev/null +++ b/activity_browser/app/actions/method/importer/method_importer_bw2io.py @@ -0,0 +1,59 @@ +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 methods 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/importer/method_importer_ecoinvent.py b/activity_browser/app/actions/method/importer/method_importer_ecoinvent.py new file mode 100644 index 000000000..a847e3b1b --- /dev/null +++ b/activity_browser/app/actions/method/importer/method_importer_ecoinvent.py @@ -0,0 +1,158 @@ +from loguru import logger + +from qtpy import QtWidgets, QtCore +from qtpy.QtCore import Signal, SignalInstance + +from activity_browser import app +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.core import threading + + + + +class MethodImporterEcoinvent(ABAction): + """ABAction to import methods from ecoinvent""" + + icon = icons.qicons.import_db + text = "Import methods from ecoinvent excel format" + tool_tip = "Import methods from ecoinvent excel format" + + @classmethod + @exception_dialogs + def run(cls): + # get the path from the user + path, _ = QtWidgets.QFileDialog.getOpenFileName( + parent=app.main_window, + caption='Choose ecoinvent methods excel to import', + filter='excel spreadsheet (*.xlsx);; All files (*.*)' + ) + if not path: + return + + # initialize the import thread, setting needed attributes + extract_thread = ExtractExcelThread(app.application) + extract_thread.path = path + 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() + + @staticmethod + def write_database(importer: EcoinventLCIAImporter): + # show the import setup dialog + import_dialog = ImportSetupDialog(importer, app.main_window) + if import_dialog.exec_() == QtWidgets.QDialog.Rejected: + return + + # setup the importer thread + importer_thread = ImportExcelThread(app.application) + importer_thread.importer = importer + importer_thread.biosphere_name = import_dialog.biosphere_name + importer_thread.prepend = import_dialog.prepend + + # setup a progress dialog + progress_dialog = widgets.ABProgressDialog.get_connected_dialog("Importing Impact Categories") + importer_thread.finished.connect(progress_dialog.deleteLater) + + progress_dialog.show() + importer_thread.start() + + +class ImportSetupDialog(QtWidgets.QDialog): + biosphere_name: str + prepend: str + + def __init__(self, importer: EcoinventLCIAImporter, parent=None): + super().__init__(parent) + self.importer = importer + + self.setWindowTitle("Import methods from ecoinvent Excel") + + self.db_chooser = widgets.ABComboBox.get_database_combobox(self) + self.button_comp = composites.HorizontalButtonsComposite("Cancel", "*OK") + + self.info = QtWidgets.QLabel() + self.info.setWordWrap(True) + self.info.setTextFormat(QtCore.Qt.RichText) + + self.prepend_label = QtWidgets.QLabel("Prepend method names") + + self.prepend_textbox = QtWidgets.QLineEdit() + self.prepend_textbox.setPlaceholderText("Enter name prepend") + 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) + + # Create final layout + layout = QtWidgets.QVBoxLayout() + layout.addWidget(QtWidgets.QLabel("Choose biosphere database:")) + layout.addWidget(self.db_chooser) + layout.addWidget(self.prepend_label) + layout.addWidget(self.prepend_textbox) + layout.addWidget(self.info) + layout.addWidget(self.button_comp) + + # Set the dialog layout + self.setLayout(layout) + self.validate() + self.check_overwrite() + + def check_overwrite(self, prepend=None) -> int: + overwrite = 0 + for name in [x["name"] for x in self.importer.data]: + if prepend: + name = tuple([prepend, *name]) + + if name in bd.methods: + overwrite += 1 + + if not overwrite: + self.info.setText("") + return overwrite + + self.info.setText( + f"

This action will overwrite {overwrite} impact categories

" + ) + + return overwrite + + def validate(self): + """Validate the user input and enable the OK button if all is clear""" + valid = True + self.button_comp["OK"].setEnabled(valid) + + def accept(self): + """Correctly set the dialog's attributes for further use in the action""" + self.biosphere_name = self.db_chooser.currentText() + self.prepend = self.prepend_textbox.text() + super().accept() + + +class ExtractExcelThread(threading.ABThread): + loaded: SignalInstance = Signal(EcoinventLCIAImporter) + path: str + + def run_safely(self): + importer = EcoinventLCIAImporter.setup_with_ei_excel(self.path) + self.loaded.emit(importer) + + +class ImportExcelThread(threading.ABThread): + biosphere_name: str + prepend: str + importer: EcoinventLCIAImporter + + def run_safely(self): + self.importer.set_biosphere(self.biosphere_name) + self.importer.apply_strategies() + self.importer.prepend_methods(self.prepend) + self.importer.write_methods(overwrite=True) + diff --git a/activity_browser/app/actions/method/method_delete.py b/activity_browser/app/actions/method/method_delete.py new file mode 100644 index 000000000..dac6cd4b0 --- /dev/null +++ b/activity_browser/app/actions/method/method_delete.py @@ -0,0 +1,93 @@ +from os import name +from typing import List +from loguru import logger + +from qtpy import QtWidgets + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + + + + +class MethodDelete(ABAction): + """ + Delete one or more impact assessment methods (impact categories). + + Flow: + - Confirm with the user. + - Deregister all selected methods from Brightway. + - Update all Brightway calculation setups by removing the deleted methods from their + 'ia' list (if present), and serialize the updated setups. + + Notes: + - Calculation setups can store method identifiers either as tuples (recommended) or + sometimes as strings for single-level names; the cleanup accounts for both. + """ + + icon = qicons.delete + text = "Delete Impact Category" + + @staticmethod + @exception_dialogs + def run(methods: List[tuple]): + # check whether we're dealing with a leaf or node. If it's a node, select all underlying methods for deletion + all_methods = [bd.Method(method) for method in methods] + + if len(all_methods) == 1: + warning_text = f"Are you sure you want to delete this method?\n\n{methods[0]}" + else: + warning_text = f"Are you sure you want to delete {len(all_methods)} methods?" + + # warn the user about the pending deletion + warning = QtWidgets.QMessageBox.warning( + app.main_window, + "Deleting Method", + warning_text, + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + # return if the users cancels + if warning == QtWidgets.QMessageBox.No: + return + + # collect names for calculation setup cleanup + to_remove = {m.name for m in all_methods} + + # delete all methods by deregistering them + for method in all_methods: + method.deregister() + logger.info(f"Deleted method {method.name}") + + # remove deleted methods from all calculation setups + MethodDelete.remove_methods_from_calculation_setups(to_remove) + + @staticmethod + def remove_methods_from_calculation_setups(method_names: set[tuple]) -> None: + """ + Remove given method names from all calculation setups' 'ia' lists and serialize. + """ + try: + changed_any = False + + for cs_name, cs in bd.calculation_setups.items(): + ia = cs.get("ia", []) + + for name in method_names: + if name not in ia: + continue # name not present, skip + + ia.remove(name) + changed_any = True + + logger.info( + f"Updated calculation setup '{cs_name}': removed impact category {name}" + ) + + + if changed_any: + bd.calculation_setups.serialize() + except Exception: + logger.exception("Failed to update calculation setups after method rename") diff --git a/activity_browser/app/actions/method/method_duplicate.py b/activity_browser/app/actions/method/method_duplicate.py new file mode 100644 index 000000000..fb63a406a --- /dev/null +++ b/activity_browser/app/actions/method/method_duplicate.py @@ -0,0 +1,149 @@ +from typing import List +from loguru import logger + +from qtpy import QtWidgets + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + +from .method_open import MethodOpen + + + + +class MethodDuplicate(ABAction): + """ + ABAction to duplicate a method, or node with all underlying methods to a new name specified by the user. + """ + + icon = qicons.copy + text = "Duplicate Impact Category" + + @staticmethod + @exception_dialogs + def run(methods: List[tuple], level: str = None): + # this action can handle only one selected method for now + selected_method = methods[0] + + # check whether we're dealing with a leaf or node. If it's a node, select all underlying methods for duplication + if level is not None and level != "leaf": + all_methods = [ + bd.Method(method) + for method in bd.methods + if set(selected_method).issubset(method) + ] + else: + all_methods = [bd.Method(selected_method)] + + # retrieve the new name(s) from the user and return if canceled + dialog = TupleNameDialog.get_combined_name( + app.main_window, + "Impact category name", + "Combined name:", + selected_method, + " - Copy", + ) + if dialog.exec_() != TupleNameDialog.Accepted: + return + + # for each method to be duplicated, construct a new location + location = dialog.result_tuple + new_names = [location + method.name[len(location) :] for method in all_methods] + + # instruct the ImpactCategoryController to duplicate the methods to the new locations + for method, new_name in zip(all_methods, new_names): + if new_name in methods: + raise Exception("New method name already in use") + method.copy(new_name) + logger.info(f"Copied method {method.name} into {new_name}") + + MethodOpen.run(new_names) + + +class TupleNameDialog(QtWidgets.QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.name_label = QtWidgets.QLabel("New name") + self.view_name = QtWidgets.QLabel() + + self.input_fields = [] + self.buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, + ) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + + layout = QtWidgets.QVBoxLayout() + row = QtWidgets.QHBoxLayout() + row.addWidget(self.name_label) + row.addWidget(self.view_name) + layout.addLayout(row) + self.input_box = QtWidgets.QGroupBox(self) + input_field_layout = QtWidgets.QVBoxLayout() + self.input_box.setLayout(input_field_layout) + layout.addWidget(self.input_box) + layout.addWidget(self.buttons) + self.setLayout(layout) + + @property + def combined_names(self) -> str: + """Reads all of the input fields in order and returns a string.""" + return ", ".join(self.result_tuple) + + @property + def result_tuple(self) -> tuple: + return tuple([f.text() for f in self.input_fields if f.text()]) + + def changed(self) -> None: + """ + Actions when the text within the TupleNameDialog is edited by the user + """ + # rebuild the combined name example + self.view_name.setText(f"'({self.combined_names})'") + + # disable the button (and its outline) when all fields are empty + if self.combined_names == "": + self.buttons.buttons()[0].setDefault(False) + self.buttons.buttons()[0].setDisabled(True) + # enable when that's not the case (anymore) + else: + self.buttons.buttons()[0].setDisabled(False) + self.buttons.buttons()[0].setDefault(True) + + def add_input_field(self, text: str) -> None: + edit = QtWidgets.QLineEdit(text, self) + edit.textChanged.connect(self.changed) + self.input_fields.append(edit) + self.input_box.layout().addWidget(edit) + + @classmethod + def get_combined_name( + cls, + parent: QtWidgets.QWidget, + title: str, + label: str, + fields: tuple, + extra: str = "Extra", + ) -> "TupleNameDialog": + """ + Set-up a TupleNameDialog pop-up with supplied title + label. Construct fields + for each field of the supplied tuple. Last field of the tuple is appended with + the extra string, to avoid duplicates. + """ + obj = cls(parent) + obj.setWindowTitle(title) + obj.name_label.setText(label) + + # set up a field for each tuple element + for i, field in enumerate(fields): + field_content = str(field) + + # if it's the last element, add extra to the string + if i + 1 == len(fields): + field_content += extra + obj.add_input_field(field_content) + obj.input_box.updateGeometry() + obj.changed() + return obj diff --git a/activity_browser/app/actions/method/method_meta_modify.py b/activity_browser/app/actions/method/method_meta_modify.py new file mode 100644 index 000000000..f917f82ff --- /dev/null +++ b/activity_browser/app/actions/method/method_meta_modify.py @@ -0,0 +1,25 @@ +from loguru import logger + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + + + + +class MethodMetaModify(ABAction): + """ + """ + + icon = qicons.delete + text = "Modify Impact Category metadata" + + @staticmethod + @exception_dialogs + def run(method_name: tuple[str], key: str, value: str): + if method_name not in bd.methods: + logger.warning(f"Can't modify metadata for method {method_name} - method not found") + return + + bd.methods[method_name][key] = value + bd.methods.flush() diff --git a/activity_browser/app/actions/method/method_new.py b/activity_browser/app/actions/method/method_new.py new file mode 100644 index 000000000..8fa03e688 --- /dev/null +++ b/activity_browser/app/actions/method/method_new.py @@ -0,0 +1,77 @@ +from loguru import logger + +from qtpy import QtWidgets + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons +from activity_browser.ui import dialogs + + +class MethodNew(ABAction): + """ + ABAction to create a new, empty impact category and open it in edit mode. + + This action prompts the user for a new method name using a list edit dialog, + validates the input, creates an empty method in Brightway2, and opens it + in the ImpactCategoryDetails page in edit mode so the user can start adding + characterization factors. + + Steps: + - Open a dialog to prompt the user for the new method name (as a tuple). + - Validate the new name to ensure it is not empty and does not already exist. + - Create and register a new empty method in Brightway2. + - Open the method in the ImpactCategoryDetails page. + - Set the page to edit mode so the user can add characterization factors. + """ + + icon = qicons.add + text = "New impact category" + + @staticmethod + @exception_dialogs + def run(): + # Open dialog to get new method name + dialog = dialogs.ABListEditDialog(("New Impact Category",), parent=app.main_window) + dialog.setWindowTitle("New Impact Category") + + if dialog.exec_() != QtWidgets.QDialog.Accepted: + return + + new_name = dialog.get_data(as_tuple=True) + + # Validate new name + if len(new_name) == 0: + QtWidgets.QMessageBox.warning( + app.main_window, + "Invalid Name", + "Impact category name cannot be empty.", + ) + return + + if new_name in bd.methods: + QtWidgets.QMessageBox.warning( + app.main_window, + "Name Already Exists", + f"An impact category with the name '{' | '.join(new_name)}' already exists.", + ) + return + + # Create new empty method + method = bd.Method(new_name) + method.register() + method.write([]) # Write empty list of characterization factors + + logger.info(f"Created new impact category: {new_name}") + + # Open the method in the ImpactCategoryDetails page + from activity_browser.app import pages + + page = pages.ImpactCategoryDetailsPage(new_name) + central = app.main_window.centralWidget() + central.addToGroup("Characterization Factors", page) + + # Set the page to edit mode + page.is_editable = True + page.sync() diff --git a/activity_browser/app/actions/method/method_open.py b/activity_browser/app/actions/method/method_open.py new file mode 100644 index 000000000..c6f9cc49d --- /dev/null +++ b/activity_browser/app/actions/method/method_open.py @@ -0,0 +1,28 @@ +from typing import List + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + + +class MethodOpen(ABAction): + """ + ABAction to open one or more supplied methods in a method tab by employing signals. + + TODO: move away from using signals like this. Probably add a method to the MainWindow to add a panel instead. + """ + + icon = qicons.right + text = "Open Impact Category" + + @staticmethod + @exception_dialogs + def run(method_names: List[tuple]): + from activity_browser.app import pages + + for name in method_names: + page = pages.ImpactCategoryDetailsPage(name) + central = app.main_window.centralWidget() + + central.addToGroup("Characterization Factors", page) + diff --git a/activity_browser/app/actions/method/method_rename.py b/activity_browser/app/actions/method/method_rename.py new file mode 100644 index 000000000..7942dd8a6 --- /dev/null +++ b/activity_browser/app/actions/method/method_rename.py @@ -0,0 +1,112 @@ +from typing import List +from loguru import logger + +from qtpy import QtWidgets + +import bw2data as bd + +from activity_browser import app +from activity_browser.ui import dialogs +from activity_browser.app.actions.base import ABAction, exception_dialogs + + + + +class MethodRename(ABAction): + """ + Rename an existing impact assessment method (impact category). + + Flow: + - Ensure only one method is selected and it exists. + - Prompt the user for the new name and validate it. + - Copy the method to the new name and process it. + - Update all Brightway calculation setups by replacing the old method in 'ia' lists with + the new name and serialize the updates. + - Emit a rename signal and deregister the old method. + + Raises: + - ValueError: If more than one method is provided for renaming. + - RuntimeError: If the method does not exist, the new name is empty, or the new name already exists. + """ + + text = "Rename Impact Category" + + @staticmethod + @exception_dialogs + def run(method_name: tuple[str] | list[tuple[str]]): + # safeguard: only allow renaming one method at a time + if isinstance(method_name, list): + if len(method_name) != 1 or not isinstance(method_name[0], tuple): + raise ValueError("Can only rename one method at a time.") + method_name = method_name[0] + + # check if method exists + if method_name not in bd.methods: + raise RuntimeError(f"Method {method_name} does not exist.") + + method = bd.Method(method_name) + + # open dialog to get new name + dialog = dialogs.ABListEditDialog( + method_name, + title="Rename Impact Category", + parent=app.main_window, + ) + + # execute the dialog and check for acceptance + if dialog.exec_() != QtWidgets.QDialog.Accepted: + return + + new_name = dialog.get_data(as_tuple=True) + + # check new name validity + if new_name == method_name: + return # no change + + if len(new_name) == 0: + raise RuntimeError("Method name cannot be empty.") + + if new_name in bd.methods: + raise RuntimeError(f"Method {new_name} already exists.") + + # copy method to new name and process + method.copy(new_name).process() + + # Update any calculation setups that reference this method + MethodRename.rename_method_in_calculation_setups(method_name, new_name) + + # this should not happen like this, as the model and therefore signals should be handled declaritavely, + # but since method renaming is not native to bw2data we have to do it manually here + app.signals.method.renamed.emit(method_name, new_name) + + # deregister old method + method.deregister() + + @staticmethod + def rename_method_in_calculation_setups(old_name: tuple, new_name: tuple) -> None: + """Replace occurrences of old_name with new_name in all CS 'ia' lists and serialize. + + Handles both tuple and single-string method keys. Best-effort: logs on failure + without blocking the rename flow. + """ + try: + changed_any = False + + for cs_name, cs in bd.calculation_setups.items(): + ia = cs.get("ia", []) + + if old_name not in ia: + continue + + i = ia.index(old_name) + ia[i] = new_name + + changed_any = True + logger.info( + f"Updated calculation setup '{cs_name}': renamed impact category {old_name} -> {new_name}" + ) + + if changed_any: + bd.calculation_setups.serialize() + except Exception: + logger.exception("Failed to update calculation setups after method rename") diff --git a/activity_browser/app/actions/migrations_install.py b/activity_browser/app/actions/migrations_install.py new file mode 100644 index 000000000..753445abb --- /dev/null +++ b/activity_browser/app/actions/migrations_install.py @@ -0,0 +1,41 @@ +from qtpy import QtWidgets + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui import icons +from activity_browser.mod.bw2io.migrations import ab_create_core_migrations +from activity_browser.ui.core import threading + + +class MigrationsInstall(ABAction): + """ + ABAction to install the default migrations from bw2io + """ + + icon = icons.qicons.import_db + text = "Install default migrations" + + @staticmethod + @exception_dialogs + def run(): + def update_dialog_slot(progress: int, label: str): + dialog.setValue(progress) + dialog.setLabelText(label) + + + dialog = QtWidgets.QProgressDialog(app.main_window) + dialog.setWindowTitle("Installing migrations") + dialog.setMaximum(100) + dialog.setCancelButton(None) + + thread = MigrationsInstallThread(app.application) + + thread.status.connect(update_dialog_slot) + + thread.start() + dialog.exec_() + + +class MigrationsInstallThread(threading.ABThread): + def run_safely(self): + ab_create_core_migrations() diff --git a/activity_browser/app/actions/node_select_open.py b/activity_browser/app/actions/node_select_open.py new file mode 100644 index 000000000..5050f7fe6 --- /dev/null +++ b/activity_browser/app/actions/node_select_open.py @@ -0,0 +1,29 @@ +from loguru import logger + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons +from activity_browser.ui.core.application import global_shortcut + +from .activity.activity_open import ActivityOpen + +class NodeSelectOpen(ABAction): + + icon = qicons.search + text = "Search project" + + @staticmethod + @global_shortcut("Ctrl+Shift+F") + @exception_dialogs + def run(): + from activity_browser.app import dialogs + dialog = dialogs.NodeSelectDialog(parent=app.main_window, drag_enabled=True) + + dialog.exec_() + if dialog.result() != dialog.DialogCode.Accepted: + return + + selected_node = dialog.get_selected_node() + if selected_node: + logger.debug(f"Opening node: {selected_node}") + ActivityOpen.run([selected_node]) \ No newline at end of file diff --git a/activity_browser/app/actions/parameter/parameter_clear_broken.py b/activity_browser/app/actions/parameter/parameter_clear_broken.py new file mode 100644 index 000000000..f020bb355 --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_clear_broken.py @@ -0,0 +1,38 @@ +from typing import Any + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from bw2data.parameters import (ActivityParameter, Group, + GroupDependency, + parameters) +from activity_browser.ui.icons import qicons + + +class ParameterClearBroken(ABAction): + """ + Take the given information and attempt to remove all the downstream parameter information. + """ + + icon = qicons.delete + text = "Clear broken parameter" + + @staticmethod + @exception_dialogs + def run(parameter: Any): + db = parameter.database + code = parameter.code + group = parameter.group + + # I'm not sure this is right, because you're removing all the exchanges from the group... + parameters.remove_exchanges_from_group(group, None, False) + ActivityParameter.delete().where( + (ActivityParameter.database == db) & (ActivityParameter.code == code) + ).execute() + + # Also clear Group if it is not in use anymore + if ( + not ActivityParameter.select() + .where(ActivityParameter.group == parameter.group) + .exists() + ): + Group.delete().where(Group.name == group).execute() + GroupDependency.delete().where(GroupDependency.group == group).execute() diff --git a/activity_browser/app/actions/parameter/parameter_delete.py b/activity_browser/app/actions/parameter/parameter_delete.py new file mode 100644 index 000000000..d29ae2297 --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_delete.py @@ -0,0 +1,67 @@ +from typing import Any + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from bw2data import get_activity +from bw2data.parameters import (ActivityParameter, Group, + GroupDependency, + parameters) +from activity_browser.ui.icons import qicons +from activity_browser.bwutils.utils import Parameter + + +class ParameterDelete(ABAction): + """ + ABAction to delete an existing parameter. + """ + + icon = qicons.delete + text = "Delete parameter..." + + @staticmethod + @exception_dialogs + def run(parameter: Any or list[Any]): + if not isinstance(parameter, list): + parameter = [parameter] + + for parameter in parameter: + if isinstance(parameter, Parameter): + parameter = parameter.to_peewee_model() + + if isinstance(parameter, ActivityParameter): + db = parameter.database + code = parameter.code + amount = ( + ActivityParameter.select() + .where( + (ActivityParameter.database == db) + & (ActivityParameter.code == code) + ) + .count() + ) + + if amount > 1: + parameter.delete_instance() + else: + group = parameter.group + act = get_activity((db, code)) + parameters.remove_from_group(group, act) + # Also clear the group if there are no more parameters in it + + if ( + not ActivityParameter.select() + .where(ActivityParameter.group == group) + .exists() + ): + Group.delete().where(Group.name == group).execute() + GroupDependency.delete().where( + GroupDependency.group == group + ).execute() + else: + parameter.delete_instance() + # After deleting things, recalculate and signal changes + parameters.recalculate() + + # No fire when everything is still fresh after recalculation, so need to fire manually to be sure everything is + # updated correctly. + app.signals.parameter.recalculated.emit() diff --git a/activity_browser/app/actions/parameter/parameter_group_delete.py b/activity_browser/app/actions/parameter/parameter_group_delete.py new file mode 100644 index 000000000..b438b747a --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_group_delete.py @@ -0,0 +1,50 @@ +from loguru import logger + +import bw2data as bd +from bw2data.parameters import (ActivityParameter, Group, + GroupDependency, + parameters) + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + + +class ParameterGroupDelete(ABAction): + """ + ABAction to delete an existing parameter. + """ + + icon = qicons.delete + text = "Delete parameter group..." + + @staticmethod + @exception_dialogs + def run(parameter_groups: list[str]): + for group in parameter_groups: + if group in ["project"] + list(bd.databases): + logger.warning(f"Cannot delete built-in parameter group '{group}'. Skipping.") + continue + + group_entry = Group.get(Group.name == group) + + # Delete all parameters in the group + params_in_group = ActivityParameter.select().where(ActivityParameter.group == group) + if any([ActivityParameter.is_dependent_on(p.name, p.group) for p in params_in_group]): + raise Exception(f"Cannot delete parameter group '{group}' because some parameters are dependencies for other parameters.") + + for param in params_in_group: + param.delete_instance() + + # Delete group dependencies + GroupDependency.delete().where(GroupDependency.group == group).execute() + # Delete the group itself + group_entry.delete_instance() + + + # After deleting things, recalculate and signal changes + parameters.recalculate() + + # No fire when everything is still fresh after recalculation, so need to fire manually to be sure everything is + # updated correctly. + app.signals.parameter.recalculated.emit() diff --git a/activity_browser/app/actions/parameter/parameter_modify.py b/activity_browser/app/actions/parameter/parameter_modify.py new file mode 100644 index 000000000..85528c687 --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_modify.py @@ -0,0 +1,57 @@ +from loguru import logger + +import bw2data as bd +from bw2data.parameters import ParameterBase, parameters, ActivityParameter, Group, GroupDependency +from peewee import DoesNotExist + +from activity_browser.ui.icons import qicons +from activity_browser.bwutils.commontasks import refresh_parameter +from activity_browser.bwutils.utils import Parameter +from activity_browser.app.actions.base import ABAction, exception_dialogs + +from .parameter_rename import ParameterRename + + + + +class ParameterModify(ABAction): + """ + ABAction to delete an existing parameter. + """ + + icon = qicons.edit + text = "Modify Parameter" + + @staticmethod + @exception_dialogs + def run(parameter: tuple | Parameter | ParameterBase, field: str, value: any): + parameter = refresh_parameter(parameter) + param_model = parameter.to_peewee_model() + + if field == "data": + param_model.data.update(value) + elif field == "name": + return ParameterRename.run(parameter, value) + elif field in dir(param_model): + setattr(param_model, field, value) + else: + param_model.data.update({field: value}) + + param_model.save() + + if field in ("amount", "formula"): + ParameterModify.fix_broken_groups() + parameters.recalculate() + + @staticmethod + def fix_broken_groups(): + groups = Group.select().execute() + for group in groups: + if group.name == "project" or group.name in bd.databases: + continue + try: + ActivityParameter._static_dependencies(group.name) + except DoesNotExist: + logger.warning(f"Removing broken parameter group {group.name}") + GroupDependency.get(GroupDependency.group == group.name).delete_instance() + group.delete_instance() diff --git a/activity_browser/app/actions/parameter/parameter_new.py b/activity_browser/app/actions/parameter/parameter_new.py new file mode 100644 index 000000000..fea852592 --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_new.py @@ -0,0 +1,208 @@ +from typing import Tuple + +from qtpy import QtCore, QtGui, QtWidgets + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils import commontasks as bc +from activity_browser.mod import bw2data as bd +from bw2data.parameters import ActivityParameter +from activity_browser.ui.icons import qicons + +PARAMETER_STRINGS = ( + "Project: Available to all other parameters", + "Database: Available to Database and Activity parameters of the same database", + "Activity: Available to Activity and exchange parameters within the group", +) +PARAMETER_FIELDS = ( + ("name", "amount"), + ("name", "amount", "database"), + ("name", "amount"), +) + + +class ParameterNew(ABAction): + """ + ABAction to create a new Parameter. Opens the ParameterWizard, returns if the wizard is canceled. Else, + checks whether the name is valid, and then instructs the ParameterController to put the new parameter in the + right group. + """ + + icon = qicons.add + text = "New parameter..." + + @staticmethod + @exception_dialogs + def run(activity_key: Tuple[str, str]): + # instantiate the ParameterWizard + wizard = ParameterWizard(activity_key, app.main_window) + + # return if the wizard is canceled + if wizard.exec_() != QtWidgets.QWizard.Accepted: + return + + # gather wizard variables + selection = wizard.selected + data = wizard.param_data + + # check whether the name is valid, otherwise return + name = data.get("name") + if name[0] in ("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-", "#"): + error = QtWidgets.QErrorMessage() + error.showMessage( + "

Parameter names must not start with a digit, hyphen, or hash character

" + ) + error.exec_() + return + + # select the right group and instruct the controller to create the parameter there + if selection == 0: + bd.parameters.new_project_parameters([data]) + elif selection == 1: + db = data.pop("database") + bd.parameters.new_database_parameters([data], db) + elif selection == 2: + group = data.pop("group") + bd.parameters.new_activity_parameters([data], group) + + +class ParameterWizard(QtWidgets.QWizard): + complete = QtCore.Signal(str, str, str) + + def __init__(self, key: tuple, parent=None): + super().__init__(parent) + + self.key = key + self.pages = ( + SelectParameterTypePage(self), + CompleteParameterPage(self), + ) + for i, p in enumerate(self.pages): + self.setPage(i, p) + + @property + def selected(self) -> int: + return self.pages[0].selected + + @property + def param_data(self) -> dict: + data = {field: self.field(field) for field in PARAMETER_FIELDS[self.selected]} + if self.selected == 2: + data["group"] = self._get_group() + data["database"] = self.key[0] + data["code"] = self.key[1] + return data + + def _get_group(self): + query = (ActivityParameter.database == self.key[0]) & ( + ActivityParameter.code == self.key[1] + ) + + if not ActivityParameter.select().where(query).count(): + app.actions.ParameterNewAutomatic.run([self.key]) + + return ActivityParameter.get(query).group + + +class SelectParameterTypePage(QtWidgets.QWizardPage): + def __init__(self, parent): + super().__init__(parent) + self.setTitle("Select the type of parameter to create.") + + self.key = parent.key + + layout = QtWidgets.QVBoxLayout() + box = QtWidgets.QGroupBox("Types:") + # Explicitly set the stylesheet to avoid parent classes overriding + box.setStyleSheet( + "QGroupBox {border: 1px solid gray; border-radius: 5px; margin-top: 7px; margin-bottom: 7px; padding: 0px}" + "QGroupBox::title {top:-7 ex;left: 10px; subcontrol-origin: border}" + ) + box_layout = QtWidgets.QVBoxLayout() + self.button_group = QtWidgets.QButtonGroup() + self.button_group.setExclusive(True) + for i, s in enumerate(PARAMETER_STRINGS): + button = QtWidgets.QRadioButton(s) + self.button_group.addButton(button, i) + box_layout.addWidget(button) + # If we have a complete key, pre-select the activity parameter btn. + if all(self.key): + self.button_group.button(2).setChecked(True) + elif self.key[0] != "": + # default to database parameter is we have something. + self.button_group.button(2).setEnabled(False) + self.button_group.button(1).setChecked(True) + else: + # If we don't have a complete key, we can't create an activity parameter + self.button_group.button(2).setEnabled(False) + self.button_group.button(0).setChecked(True) + box.setLayout(box_layout) + layout.addWidget(box) + self.setLayout(layout) + + @property + def selected(self) -> int: + return self.button_group.checkedId() + + +class CompleteParameterPage(QtWidgets.QWizardPage): + def __init__(self, parent): + super().__init__(parent) + self.setTitle("Fill out required values for the parameter") + self.parent = parent + + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + box = QtWidgets.QGroupBox("Data:") + box.setStyleSheet( + "QGroupBox {border: 1px solid gray; border-radius: 5px; margin-top: 7px; margin-bottom: 7px; padding: 0px}" + "QGroupBox::title {top:-7 ex;left: 10px; subcontrol-origin: border}" + ) + grid = QtWidgets.QGridLayout() + box.setLayout(grid) + layout.addWidget(box) + + self.key = parent.key + + self.name_label = QtWidgets.QLabel("Name:") + self.name = QtWidgets.QLineEdit() + grid.addWidget(self.name_label, 0, 0) + grid.addWidget(self.name, 0, 1) + self.amount_label = QtWidgets.QLabel("Amount:") + self.amount = QtWidgets.QLineEdit() + locale = QtCore.QLocale(QtCore.QLocale.English) + locale.setNumberOptions(QtCore.QLocale.RejectGroupSeparator) + validator = QtGui.QDoubleValidator() + validator.setLocale(locale) + self.amount.setValidator(validator) + grid.addWidget(self.amount_label, 1, 0) + grid.addWidget(self.amount, 1, 1) + self.database_label = QtWidgets.QLabel("Database:") + self.database = QtWidgets.QComboBox() + grid.addWidget(self.database_label, 2, 0) + grid.addWidget(self.database, 2, 1) + + # Register fields for all possible values + self.registerField("name*", self.name) + self.registerField("amount", self.amount) + self.registerField("database", self.database, "currentText") + + def initializePage(self) -> None: + self.amount.setText("1.0") + if self.parent.selected == 0: + self.name.clear() + self.database.setHidden(True) + self.database_label.setHidden(True) + elif self.parent.selected == 1: + self.name.clear() + self.database.clear() + dbs = list(bd.databases) + self.database.insertItems(0, dbs) + if self.key[0] in dbs: + self.database.setCurrentIndex(dbs.index(self.key[0])) + self.database.setHidden(False) + self.database_label.setHidden(False) + elif self.parent.selected == 2: + self.name.clear() + self.database.setHidden(True) + self.database_label.setHidden(True) diff --git a/activity_browser/app/actions/parameter/parameter_new_automatic.py b/activity_browser/app/actions/parameter/parameter_new_automatic.py new file mode 100644 index 000000000..4fd69599d --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_new_automatic.py @@ -0,0 +1,51 @@ +from typing import List, Tuple + +from peewee import IntegrityError +from qtpy import QtWidgets + +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from bw2data.parameters import ActivityParameter +from activity_browser.ui.icons import qicons + + +class ParameterNewAutomatic(ABAction): + """ + ABAction for the automatic creation of a new parameter. + + TODO: Remove this action as it is automatic and not user interaction, should be done through e.g. a signal but + TODO: will actually need to be reworked together with the parameters. + """ + + icon = qicons.add + text = "New parameter..." + + @staticmethod + @exception_dialogs + def run(activities: List[tuple | int | bd.Node]): + activities = [refresh_node(x) for x in activities] + + for act in activities: + if act.get("type", "process") not in bd.labels.lci_node_types: + issue = f"Activity must be 'process' type, '{act.get('name')}' is type '{act.get('type')}'." + QtWidgets.QMessageBox.warning( + app.main_window, + "Not allowed", + issue, + QtWidgets.QMessageBox.Ok, + QtWidgets.QMessageBox.Ok, + ) + return + + group = act.id + row = { + "name": "dummy_parameter", + "amount": act.get("amount", 1.0), + "formula": act.get("formula", ""), + "database": act.get("database", ""), + "code": act.get("code", ""), + } + + bd.parameters.new_activity_parameters([row], group) diff --git a/activity_browser/app/actions/parameter/parameter_new_from_parameter.py b/activity_browser/app/actions/parameter/parameter_new_from_parameter.py new file mode 100644 index 000000000..0ba0757b0 --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_new_from_parameter.py @@ -0,0 +1,61 @@ +from ast import literal_eval + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.utils import Parameter +from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, parameters +from activity_browser.ui.icons import qicons + +from .parameter_new_automatic import ParameterNewAutomatic + + +class ParameterNewFromParameter(ABAction): + """ + ABAction to create a new Parameter from an instantiated Parameter namedtuple + """ + + icon = qicons.add + text = "New parameter..." + + @staticmethod + @exception_dialogs + def run(parameter: Parameter): + if not isinstance(parameter, Parameter) or parameter.param_type is None: + raise ValueError("Parameter must be an instance of Parameter") + + if not parameter.name.isidentifier(): + raise ValueError("Parameter name must be a valid Python identifier") + + # select the right group and instruct the controller to create the parameter there + if parameter.param_type == "project": + ProjectParameter( + name=parameter.name, + formula=parameter.data.get("formula", None), + amount=parameter.amount, + data=parameter.data, + ).save() + elif parameter.param_type == "database": + DatabaseParameter( + database=parameter.group, + name=parameter.name, + formula=parameter.data.get("formula", None), + amount=parameter.amount, + data=parameter.data, + ).save() + elif parameter.param_type == "activity": + mock = ActivityParameter.get_or_none(group=parameter.group) + if mock is None: + ParameterNewAutomatic.run([int(parameter.group)]) + mock = ActivityParameter.get(group=parameter.group) + + ActivityParameter( + group=parameter.group, + database=mock.database, + code=mock.code, + name=parameter.name, + formula=parameter.data.get("formula", None), + amount=parameter.amount, + data=parameter.data, + ).save() + + parameters.recalculate() + diff --git a/activity_browser/app/actions/parameter/parameter_rename.py b/activity_browser/app/actions/parameter/parameter_rename.py new file mode 100644 index 000000000..8fa768498 --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_rename.py @@ -0,0 +1,48 @@ +from qtpy import QtWidgets + +from bw2data.parameters import ParameterBase, parameters + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons +from activity_browser.bwutils.utils import Parameter +from activity_browser.bwutils.commontasks import refresh_parameter + + +class ParameterRename(ABAction): + """ + ABAction to rename an existing parameter. Constructs a dialog for the user in which they choose the new name. If no + name is chosen, or the user cancels: return. Else, instruct the ParameterController to rename the parameter using + the given name. + """ + + icon = qicons.edit + text = "Rename parameter..." + + @staticmethod + @exception_dialogs + def run(parameter: tuple | Parameter | ParameterBase, new_name: str = None): + parameter = refresh_parameter(parameter) + + new_name = new_name or ParameterRename.get_new_name(parameter) + + if not new_name: + return + + if not new_name.isidentifier(): + raise ValueError("Parameter name must be a valid Python identifier") + + getattr(parameters, f"rename_{parameter.param_type}_parameter")( + parameter.to_peewee_model(), new_name, update_dependencies=True + ) + + @staticmethod + def get_new_name(parameter: Parameter): + new_name, ok = QtWidgets.QInputDialog.getText( + app.main_window, + "Rename parameter", + f"Rename parameter '{parameter.name}' to:", + ) + + if ok and new_name: + return new_name diff --git a/activity_browser/app/actions/parameter/parameter_uncertainty_modify.py b/activity_browser/app/actions/parameter/parameter_uncertainty_modify.py new file mode 100644 index 000000000..ea8ba7e87 --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_uncertainty_modify.py @@ -0,0 +1,36 @@ +from typing import Any + +import bw2data as bd + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser import app +from activity_browser.ui.dialogs import UncertaintyDialog +from activity_browser.ui.icons import qicons + + +class ParameterUncertaintyModify(ABAction): + """ + ABAction to modify the uncertainty of an existing parameter. + """ + + icon = qicons.edit + text = "Modify parameter uncertainty" + + @staticmethod + @exception_dialogs + def run(parameter: Any, uncertainty_dict: dict=None) -> None: + + if not uncertainty_dict: + initial = parameter.dict.copy() if "uncertainty type" in parameter.dict else None + + ok, uncertainty_dict = UncertaintyDialog.get_uncertainty_dict( + parent=app.main_window, + initial=initial, + ) + + if not ok: + return + + parameter.data.update(uncertainty_dict) + parameter.save() + bd.parameters.recalculate() diff --git a/activity_browser/app/actions/parameter/parameter_uncertainty_remove.py b/activity_browser/app/actions/parameter/parameter_uncertainty_remove.py new file mode 100644 index 000000000..7210bc8d2 --- /dev/null +++ b/activity_browser/app/actions/parameter/parameter_uncertainty_remove.py @@ -0,0 +1,22 @@ +from typing import Any + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils import uncertainty +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + + +class ParameterUncertaintyRemove(ABAction): + """ + ABAction to remove the uncertainty of a parameter. + """ + + icon = qicons.delete + text = "Remove parameter uncertainty" + + @staticmethod + @exception_dialogs + def run(parameter: Any): + parameter.data.update(uncertainty.EMPTY_UNCERTAINTY) + parameter.save() + bd.parameters.recalculate() diff --git a/activity_browser/app/actions/project/project_create_template.py b/activity_browser/app/actions/project/project_create_template.py new file mode 100644 index 000000000..937767f4b --- /dev/null +++ b/activity_browser/app/actions/project/project_create_template.py @@ -0,0 +1,93 @@ +import os +import json +import tarfile +from loguru import logger + +from qtpy import QtWidgets, QtCore +import platformdirs + +from activity_browser import app +from activity_browser.mod import bw2data as bd +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.core.threading import ABThread + + + + +class ProjectCreateTemplate(ABAction): + """ + ABAction to export the current project. Prompts the user to return a save-file location. And then start a thread to + package the project and save it there. Saving code copied from bw2data.backup. + """ + icon = app.application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) + text = "Create template from project" + tool_tip = "Export project to file" + + @staticmethod + @exception_dialogs + def run(project_name: str = None, parent=None): + """Export the current project to a folder chosen by the user.""" + if project_name is None: + project_name = bd.projects.current + + # get target path from the user + template_name, ok = QtWidgets.QInputDialog.getText( + parent if parent else app.main_window, + "Create template from project", + f"Creating new template from project ({project_name}):" + + " " * 10, + ) + + if not ok or not template_name: + return + + template_file = template_name.strip() + ".tar.gz" + base_dir = platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="ActivityBrowser") + template_path = os.path.join(base_dir, "templates", template_file) + + os.makedirs(os.path.join(base_dir, "templates"), exist_ok=True) + + if os.path.exists(template_path): + QtWidgets.QMessageBox.information( + app.main_window, + "Not possible.", + "A template with this name already exists.", + ) + return + + # setup dialog + progress = QtWidgets.QProgressDialog( + parent=parent if parent else app.main_window, + labelText="Creating template", + maximum=0 + ) + progress.setCancelButton(None) + progress.setWindowTitle("Creating template") + progress.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False) + progress.setWindowFlag(QtCore.Qt.WindowCloseButtonHint, False) + progress.findChild(QtWidgets.QProgressBar).setTextVisible(False) + progress.resize(400, 100) + progress.show() + + thread = TemplateThread(app.application) + setattr(thread, "save_path", template_path) + setattr(thread, "project_name", project_name) + thread.finished.connect(lambda: progress.deleteLater()) + thread.start() + + +class TemplateThread(ABThread): + save_path: str + project_name: str + + def run_safely(self): + project_dir = str(os.path.join(bd.projects._base_data_dir, bd.utils.safe_filename(self.project_name))) + + with open(os.path.join(project_dir, ".project-name.json"), "w") as f: + json.dump({"name": self.project_name}, f) + + logger.info("Creating project template - this could take a few minutes...") + with tarfile.open(self.save_path, "w:gz") as tar: + tar.add(project_dir, arcname=bd.utils.safe_filename(self.project_name)) + + logger.info(f"Created template from `{self.project_name}`.") diff --git a/activity_browser/app/actions/project/project_delete.py b/activity_browser/app/actions/project/project_delete.py new file mode 100644 index 000000000..3552f3825 --- /dev/null +++ b/activity_browser/app/actions/project/project_delete.py @@ -0,0 +1,134 @@ +import shutil + +from qtpy import QtWidgets + +import bw2data as bd +from bw2data.project import ProjectDataset +from bw2data.utils import safe_filename + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons + +from .project_switch import ProjectSwitch + + +class ProjectDelete(ABAction): + """ + Deletes the specified projects or the currently active project if no project names are provided. + + This method handles the deletion of Brightway2 projects. It ensures that the startup project + cannot be deleted, prompts the user for confirmation, and optionally deletes the project + directories from the hard disk. + + Args: + project_names (list of str, optional): A list of project names to delete. If None, the + currently active project is selected. + + Steps: + - If no project names are provided, use the currently active project. + - Return immediately if the project list is empty. + - Prevent deletion of the startup project and notify the user if attempted. + - Open a confirmation dialog for the user to approve the deletion. + - If the user cancels, return without deleting. + - If the currently active project is being deleted, switch to the startup project. + - Delete the specified projects, optionally removing their directories from the hard disk. + - Notify the user of successful deletion. + + Raises: + None + """ + + icon = qicons.delete + text = "Delete this project" + tool_tip = "Delete the project" + + @staticmethod + @exception_dialogs + def run(project_names: list[str] = None): + if project_names is None: + # get the current project + project_names = [bd.projects.current] + + if len(project_names) == 0: + return + + # if it's the startup project: reject deletion and inform user + if app.settings["startup"]["startup_project"] in project_names: + QtWidgets.QMessageBox.information( + app.main_window, + "Not possible", + "Can't delete the startup project. Please select another startup project in the settings first.", + ) + return + + # open a delete dialog for the user to confirm, return if user rejects + delete_dialog = ProjectDeletionDialog(project_names, app.main_window) + if delete_dialog.exec_() != ProjectDeletionDialog.Accepted: + return + + # try to delete the project, delete directory if user specified so + if bd.projects.current in project_names: + ProjectSwitch.run(settings.ab_settings.startup_project) + + for project in project_names: + ProjectDelete.delete_project(project, delete_dialog.deletion_warning_checked()) + + # inform the user of successful deletion + QtWidgets.QMessageBox.information( + app.main_window, "Project(s) deleted", "Project(s) successfully deleted" + ) + + @staticmethod + def delete_project(name: str, delete_dir: bool): + + ds = ProjectDataset.get(ProjectDataset.name == name) + + if delete_dir: + dir_path = bd.projects._base_data_dir / safe_filename(name, full=ds.full_hash) + assert dir_path.is_dir(), "Can't find project directory" + shutil.rmtree(dir_path) + + ds.delete_instance() + + # THIS SHOULD NOT HAPPEN HERE BUT bw2data HAS NO SIGNALS FOR PROJECT DELETION + app.signals.project.deleted.emit(name) + + +class ProjectDeletionDialog(QtWidgets.QDialog): + + def __init__(self, projects: list[str], parent=None): + super().__init__(parent) + + self.title = "Confirm project deletion" + + if len(projects) == 1: + self.label = QtWidgets.QLabel( + f"Final confirmation to remove project: {projects[0]}.\n" + + "Warning: Non reversible process!" + ) + else: + self.label = QtWidgets.QLabel( + f"Final confirmation to remove {len(projects)} projects.\n" + + "Warning: Non reversible process!" + ) + self.check = QtWidgets.QVBoxLayout() + self.hd_check = QtWidgets.QCheckBox(f"Remove from the hard disk") + self.hd_check.setChecked(True) + + self.buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, + ) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + + self.setWindowTitle(self.title) + self.layout = QtWidgets.QVBoxLayout() + self.layout.addWidget(self.label) + self.layout.addWidget(self.hd_check) + self.layout.addWidget(self.buttons) + + self.setLayout(self.layout) + + def deletion_warning_checked(self): + return self.hd_check.isChecked() diff --git a/activity_browser/app/actions/project/project_duplicate.py b/activity_browser/app/actions/project/project_duplicate.py new file mode 100644 index 000000000..c6701491b --- /dev/null +++ b/activity_browser/app/actions/project/project_duplicate.py @@ -0,0 +1,65 @@ +from qtpy import QtWidgets + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + +from .project_switch import ProjectSwitch + + +class ProjectDuplicate(ABAction): + """ + Duplicate the current project to a new name. + + This method prompts the user to input a new name for duplicating the current project. + It performs validation to ensure the new name is not empty and does not already exist. + If the provided name is valid, the current project is duplicated to the new name, and + the application switches to the newly created project. + + Args: + name (str, optional): The name of the current project to duplicate. Defaults to the + currently active project. + + Steps: + - If no name is provided, use the current project name. + - Prompt the user for a new project name. + - Return if the user cancels or provides an empty name. + - Check if the new name already exists and show an error message if it does. + - If the provided name is not the current project, set it as the current project. + - Duplicate the project to the new name without switching to it. + - Switch to the newly created project using the `ProjectSwitch` action. + """ + + icon = qicons.copy + text = "Duplicate this project" + tool_tip = "Duplicate the project" + + @staticmethod + @exception_dialogs + def run(name: str = None): + if name is None: + name = bd.projects.current + + new_name, ok = QtWidgets.QInputDialog.getText( + app.main_window, + "Duplicate current project", + f"Duplicate project ({name}) to new name:" + + " " * 10, + ) + + if not ok or not new_name: + return + + if new_name in bd.projects: + QtWidgets.QMessageBox.information( + app.main_window, + "Not possible.", + "A project with this name already exists.", + ) + return + + if name != bd.projects.current: + bd.projects.set_current(name, update=False) + bd.projects.copy_project(new_name, switch=False) # don't switch because it will auto-update bw2 projects + ProjectSwitch.run(new_name) # switch using the action instead diff --git a/activity_browser/app/actions/project/project_export.py b/activity_browser/app/actions/project/project_export.py new file mode 100644 index 000000000..8b9a83f28 --- /dev/null +++ b/activity_browser/app/actions/project/project_export.py @@ -0,0 +1,81 @@ +import os +import json +import tarfile +from loguru import logger + +from qtpy import QtWidgets, QtCore + +import bw2data as bd +from bw2data.project import ProjectDataset + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.core.threading import ABThread + + + + +class ProjectExport(ABAction): + """ + ABAction to export the current project. Prompts the user to return a save-file location. And then start a thread to + package the project and save it there. Saving code copied from bw2data.backup. + """ + icon = app.application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) + text = "&Export this project..." + tool_tip = "Export project to file" + + @staticmethod + @exception_dialogs + def run(project_name: str = None): + """Export the current project to a folder chosen by the user.""" + if project_name is None: + project_name = bd.projects.current + + # get target path from the user + save_path, save_type = QtWidgets.QFileDialog.getSaveFileName( + parent=app.main_window, + caption="Choose where", + dir=os.path.expanduser(f"~/{project_name}.tar.gz"), + filter="Tar-file (*.tar.gz)" + ) + + if not save_path: return + + # setup dialog + progress = QtWidgets.QProgressDialog( + parent=app.main_window, + labelText="Exporting project", + maximum=0 + ) + progress.setCancelButton(None) + progress.setWindowTitle("Exporting project") + progress.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False) + progress.setWindowFlag(QtCore.Qt.WindowCloseButtonHint, False) + progress.findChild(QtWidgets.QProgressBar).setTextVisible(False) + progress.resize(400, 100) + progress.show() + + thread = ExportThread(app.application) + setattr(thread, "save_path", save_path) + setattr(thread, "project_name", project_name) + thread.finished.connect(lambda: progress.deleteLater()) + thread.start() + + +class ExportThread(ABThread): + save_path: str + project_name: str + + def run_safely(self): + ds = ProjectDataset.get(ProjectDataset.name == self.project_name) + project_folder_name = bd.utils.safe_filename(self.project_name, full=ds.full_hash) + project_dir = os.path.join(bd.projects._base_data_dir, project_folder_name) + + with open(os.path.join(project_dir, ".project-name.json"), "w") as f: + json.dump({"name": self.project_name}, f) + + logger.info("Creating project backup archive - this could take a few minutes...") + with tarfile.open(self.save_path, "w:gz") as tar: + tar.add(project_dir, arcname=bd.utils.safe_filename(self.project_name)) + + logger.info(f"Project `{self.project_name}` exported.") diff --git a/activity_browser/app/actions/project/project_import.py b/activity_browser/app/actions/project/project_import.py new file mode 100644 index 000000000..002797b9a --- /dev/null +++ b/activity_browser/app/actions/project/project_import.py @@ -0,0 +1,116 @@ +import codecs +import json +import tarfile +from loguru import logger + +import bw2data as bd +from qtpy import QtWidgets, QtCore +from bw2io import backup + +from activity_browser import app +from activity_browser.mod import bw2data as bd +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons +from activity_browser.ui.core.threading import ABThread + + + + +class ProjectImport(ABAction): + """ + ABAction to import a new project. Prompts the user to select a file. Imports the project name from the file as a + suggestion. Prompts user to either accept the name or change it. If the name already exists, try again. Else, + perform the import in a separate thread and show a progress dialog until it is finished. Finally, move to the newly + imported project. + """ + icon = qicons.import_db + text = "&Import a project..." + tool_tip = "Import project from a file" + + @classmethod + @exception_dialogs + def run(cls): + """Import a project into AB based on file chosen by user.""" + + # get the path from the user + path, _ = QtWidgets.QFileDialog.getOpenFileName( + parent=app.main_window, + caption='Choose project file to import', + filter='Tar archive (*.tar.gz);; All files (*.*)' + ) + if not path: return + + # create a name suggestion based on the file name + suggestion = cls.get_project_name(path) + + # get a new project name from the user: + while True: + project_name, _ = QtWidgets.QInputDialog.getText( + app.main_window, + 'Choose project name', + 'Choose a name for your project', + text=suggestion + ) + + if not project_name: return + + if project_name in bd.projects: + # this name already exists, inform user and ask again. + QtWidgets.QMessageBox.information( + app.main_window, + "Not possible.", + "A project with this name already exists." + ) + else: break + + # setup dialog + progress = QtWidgets.QProgressDialog( + parent=app.main_window, + labelText="Importing project", + maximum=0 + ) + progress.setCancelButton(None) + progress.setWindowTitle("Importing project") + progress.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False) + progress.setWindowFlag(QtCore.Qt.WindowCloseButtonHint, False) + progress.findChild(QtWidgets.QProgressBar).setTextVisible(False) + progress.resize(400, 100) + progress.show() + + # setup the import + thread = ImportThread(app.application) + setattr(thread, "path", path) + setattr(thread, "project_name", project_name) + + thread.finished.connect(lambda: progress.deleteLater()) + thread.finished.connect(lambda: bd.projects.set_current(project_name, update=False)) + + # start the import + thread.start() + + @staticmethod + def get_project_name(fp): + reader = codecs.getreader("utf-8") + # See https://stackoverflow.com/questions/68997850/python-readlines-with-tar-file-gives-streamerror-seeking-backwards-is-not-al/68998071#68998071 + with tarfile.open(fp, "r:gz") as tar: + for member in tar: + if member.name[-17:] == "project-name.json": + return json.load(reader(tar.extractfile(member)))["name"] + return "" + + +class ImportThread(ABThread): + + def run_safely(self): + logger.debug('Starting project import:' + f'\nPATH: {self.path}' + f'\nNAME: {self.project_name}') + backup.restore_project_directory(fp=self.path, project_name=self.project_name) + + # fix wrong hashing + ds = bd.project.ProjectDataset.get(name=self.project_name) + ds.full_hash = False + ds.save() + + logger.info(f"Project `{self.project_name}` imported.") + diff --git a/activity_browser/app/actions/project/project_local_import.py b/activity_browser/app/actions/project/project_local_import.py new file mode 100644 index 000000000..b4029806f --- /dev/null +++ b/activity_browser/app/actions/project/project_local_import.py @@ -0,0 +1,278 @@ +import json +from tarfile import open as tar_open, TarFile, TarError +from loguru import logger + +from qtpy import QtWidgets, QtCore +from bw2io import restore_project_directory + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui import icons, widgets + + + + +class ProjectLocalImportWindow(QtWidgets.QDialog): + + MAX_PROJECT_NAME_JSON_SIZE = 1024 + PROJECT_FILE = ".project-name.json" + + def __init__(self): + super().__init__() + self.setWindowTitle("Import project from file") + self.setSizePolicy( + QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + ) + + layout = QtWidgets.QVBoxLayout() + dialog_spacing = 8 + + file_chooser_layout = QtWidgets.QHBoxLayout() + file_chooser_layout.setAlignment(QtCore.Qt.AlignLeft) + tarball_label = QtWidgets.QLabel("Project file:") + self._selected_file_edit = QtWidgets.QLineEdit() + self._selected_file_edit.setMinimumWidth(300) + self._selected_file_edit.textChanged.connect(self._load_project_name) + self._browse_button = QtWidgets.QPushButton("Browse") + self._browse_button.clicked.connect(self._handle_browse_clicked) + file_chooser_layout.addWidget(tarball_label) + file_chooser_layout.addWidget(self._selected_file_edit) + file_chooser_layout.addWidget(self._browse_button) + + layout.addLayout(file_chooser_layout) + + layout.addSpacing(dialog_spacing) + + project_name_layout = QtWidgets.QHBoxLayout() + project_name_layout.setAlignment(QtCore.Qt.AlignLeft) + project_name_layout.addWidget(widgets.ABLabel.demiBold("Project name:")) + self.project_name = QtWidgets.QLineEdit() + self.project_name.setText("") + self.project_name.textChanged.connect(self._handle_project_name_changed) + project_name_layout.addWidget(self.project_name) + layout.addLayout(project_name_layout) + + self._overwrite_checkbox = QtWidgets.QCheckBox("Overwrite existing project") + self._overwrite_checkbox.clicked.connect(self._handle_overwrite_clicked) + layout.addWidget(self._overwrite_checkbox) + + self._activate_project_checkbox = QtWidgets.QCheckBox("Activate project after import") + self._activate_project_checkbox.setChecked(True) + layout.addWidget(self._activate_project_checkbox) + + import_button_layout = QtWidgets.QHBoxLayout() + self.import_button = QtWidgets.QPushButton("Create project") + import_button_layout.addWidget(self.import_button) + self.import_button.clicked.connect(self._import_project) + layout.addLayout(import_button_layout) + self._message_label = QtWidgets.QLabel() + + layout.addWidget(self._message_label) + + self.setLayout(layout) + self._last_url = "" + self._loaded_project_name = "" + self._reset_dialog() + self._message_label.setText("Select a project file") + + def _reset_dialog(self): + self.project_name.setEnabled(False) + self.project_name.setPlaceholderText("") + self._overwrite_checkbox.setEnabled(False) + self._overwrite_checkbox.setChecked(False) + self._activate_project_checkbox.setEnabled(False) + self.import_button.setEnabled(False) + self._message_label.setText("") + + def _enable_ui(self): + self.project_name.setEnabled(True) + self.project_name.setPlaceholderText("") + self._activate_project_checkbox.setEnabled(True) + self._message_label.setText("") + + def _handle_browse_clicked(self): + """Open a system file dialog and allow the user to select a file""" + file = QtWidgets.QFileDialog().getOpenFileName( + self, + "Select archive file", + filter = "Tar GZ (*.tar.gz)" + )[0] + # The returned value is None on Cancel + if file: + self._selected_file_edit.setText(QtCore.QDir.toNativeSeparators(file)) + self._load_project_name() + + def _decode_project_name(self, tar: TarFile): + """ + Get the list of files from the TarFile, and decode the name + from the .project-name.json. + + Updates the UI with error messages if it fails. + """ + # all files in the archive + name_list = tar.getnames() + # list of files, where the path contains ".project-name.json" + project_name_files = [name for name in name_list if self.PROJECT_FILE in name] + if len(project_name_files) == 0: + self._message_label.setText( + f"No '{self.PROJECT_FILE}' file found in project file" + ) + return + if len(project_name_files) > 1: + self._message_label.setText( + f"More than one '{self.PROJECT_FILE}' file found in project file" + ) + return + # choose the first one, we expect to have only one + project_name_file = project_name_files[0] + # get TarInfo for it + tar_info_project_name_file = tar.getmember(project_name_file) + # prevent too big files from being extracted + if tar_info_project_name_file.size > self.MAX_PROJECT_NAME_JSON_SIZE: + self._message_label.setText( + f"Size of '{self.PROJECT_FILE}' file is too " + f"big: {tar_info_project_name_file.size}" + ) + return + # get extracter BufferedReader + if extracter := tar.extractfile(project_name_file): + try: + # JSON should have a single string value with the key "name" + project_name = json.loads(extracter.read())["name"] + except: + self._message_label.setText( + "Failed to decode project name" + ) + return + if project_name == "": + self._message_label.setText("Decoded project name is empty") + return + self._enable_ui() + self._loaded_project_name = project_name + self.project_name.setPlaceholderText(self._loaded_project_name) + self._check_project_already_exists() + + def _load_project_name(self): + """Exception handling for the project name decoding.""" + try: + self._reset_dialog() + archive = self._selected_file_edit.text() + tar = tar_open(archive, "r:gz") + except FileNotFoundError: + self._message_label.setText("Project file not found") + except TarError: + self._message_label.setText("Error opening project file") + except (ValueError, OSError): + self._message_label.setText("Select a project file") + else: + try: + with tar: + self._decode_project_name(tar) + except TarError: + self._message_label.setText("Error opening project file") + + def _selected_project_name(self) -> str: + """The name of the project as decoded from the tarball""" + return self._loaded_project_name + + def _project_name(self) -> str: + """Return the user typed project name or, if empty, the loaded one.""" + if self.project_name.text() == "": + return self._selected_project_name() + return self.project_name.text() + + def _handle_project_name_changed(self): + """Trigger duplicate project name check""" + self._check_project_already_exists() + + def _unique_project_update(self): + """ + Update the UI when the entered project name is unique. + """ + self.import_button.setEnabled(True) + self._message_label.setText("") + + def _duplicate_project_checkbox_update(self): + """ + Update the UI when the overwrite checkbox state changes and the + project name is not unique. + + Use the actual state of the checkbox, because it is not + called only from the checkbox click event. + """ + if self._overwrite_checkbox.isChecked(): + self.import_button.setEnabled(True) + self._message_label.setText("") + else: + self.import_button.setEnabled(False) + self._message_label.setText("Project name already exists") + + def _handle_overwrite_clicked(self): + self._duplicate_project_checkbox_update() + + def _check_project_already_exists(self): + """ + Update the overwrite checkbox and import button based on the project name. + + If the project already exists, it can only be imported with the + overwrite flag set. To make sure the user does not import it accidentaly, + the flag is reset every time the selected project or the project name changes. + """ + self._overwrite_checkbox.setChecked(False) + if self._project_name() in bd.projects: + self._overwrite_checkbox.setEnabled(True) + self._duplicate_project_checkbox_update() + else: + self._overwrite_checkbox.setEnabled(False) + self._unique_project_update() + + def _import_project(self): + """ + Import the selected project with the new name. + It is checked with the UI flow, that there is a tarball loaded, + and a unique name provided or the overwrite flag is set. + """ + original_name = self._selected_project_name() + new_name = self._project_name() + if original_name and new_name: + logger.info(f"Importing project with name {new_name} " + f"(original name {original_name})") + self.import_button.setText("Creating project...") + self.import_button.setEnabled(False) + self.repaint() + self.setCursor(QtCore.Qt.WaitCursor) + + restore_project_directory( + self._selected_file_edit.text(), + new_name, + overwrite_existing=self._overwrite_checkbox.isChecked() + ) + if self._activate_project_checkbox.isChecked(): + bd.projects.set_current(new_name, update=False) + self.setCursor(QtCore.Qt.ArrowCursor) + self.accept() + else: + logger.error( + f"Project name ({new_name}) or " + f"import name ({original_name}) is not valid." + ) + + +class ProjectLocalImport(ABAction): + """ + ABAction to download a project file from a remote server. + Allows for customization of server URL, created project name, and whether or not to overwrite existing projects. + """ + + icon = icons.qicons.import_db + text = "Import local project" + tool_tip = "Import a project file from a remote server" + + @staticmethod + @exception_dialogs + def run(): + window = ProjectLocalImportWindow() + window.adjustSize() + window.exec_() diff --git a/activity_browser/app/actions/project/project_manager_open.py b/activity_browser/app/actions/project/project_manager_open.py new file mode 100644 index 000000000..5bada520f --- /dev/null +++ b/activity_browser/app/actions/project/project_manager_open.py @@ -0,0 +1,26 @@ +from qtpy import QtCore + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs + +from activity_browser.ui.icons import qicons + + +class ProjectManagerOpen(ABAction): + """ + ABAction to delete a database from the project. Asks the user for confirmation. If confirmed, instructs the + DatabaseController to delete the database in question. + """ + + icon = qicons.delete + text = "Open project manager" + + @staticmethod + @exception_dialogs + def run(): + from activity_browser.app.panes import ProjectManagerPane + + project_manager = ProjectManagerPane(app.main_window) + app.main_window.addDockWidget( + QtCore.Qt.LeftDockWidgetArea, + project_manager.getDockWidget(app.main_window)) diff --git a/activity_browser/app/actions/project/project_migrate25.py b/activity_browser/app/actions/project/project_migrate25.py new file mode 100644 index 000000000..209bd4364 --- /dev/null +++ b/activity_browser/app/actions/project/project_migrate25.py @@ -0,0 +1,164 @@ +from tqdm import tqdm +from loguru import logger +from qtpy import QtWidgets, QtGui, QtCore + +import bw2data as bd +import pandas as pd + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.icons import qicons +from activity_browser.ui.core.threading import ABThread + + + + +class ProjectMigrate25(ABAction): + """ + ABAction to duplicate a project. Asks the user for a new name. Returns if no name is given, the user cancels, or + when the name is already in use by another project. Else, instructs the ProjectController to duplicate the current + project to the new name. + """ + + icon = qicons.copy + text = "Migrate project" + tool_tip = "Migrate the project to bw25" + + @staticmethod + @exception_dialogs + def run(name: str = None): + if name is None: + name = bd.projects.current + + dialog = MigrateDialog(name, app.main_window) + dialog.exec_() + + if dialog.result() == dialog.DialogCode.Rejected: + return + + if name != bd.projects.current: + bd.projects.set_current(name, update=False) + + # setup dialog + progress = QtWidgets.QProgressDialog( + parent=app.main_window, + labelText="Migrating project, this may take a while...", + maximum=0 + ) + progress.setCancelButton(None) + progress.setWindowTitle("Migrating project to Brightway25") + progress.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False) + progress.setWindowFlag(QtCore.Qt.WindowCloseButtonHint, False) + progress.findChild(QtWidgets.QProgressBar).setTextVisible(False) + progress.resize(400, 100) + progress.show() + + thread = MigrateThread(app.application) + thread.finished.connect(lambda: progress.deleteLater()) + thread.start() + thread.connect_progress_dialog(progress) + + +class MigrateDialog(QtWidgets.QDialog): + def __init__(self, project_name: str, parent=None): + from .project_export import ProjectExport + + super().__init__(parent) + self.setWindowTitle("Migrate project") + label = QtWidgets.QLabel(f"Migrate project ({project_name}) from legacy to Brightway25. This cannot be undone.") + + cancel = QtWidgets.QPushButton("Cancel") + migrate = QtWidgets.QPushButton("Migrate") + backup = ProjectExport.get_QButton(project_name) + backup.setText("Back-up project") + backup.setIcon(QtGui.QIcon()) + + cancel.clicked.connect(self.reject) + migrate.clicked.connect(self.accept) + + button_layout = QtWidgets.QHBoxLayout() + button_layout.addWidget(backup) + button_layout.addStretch() + button_layout.addWidget(cancel) + button_layout.addWidget(migrate) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(label) + layout.addLayout(button_layout) + + self.setLayout(layout) + + +class MigrateThread(ABThread): + def run_safely(self): + self.pre_process_methods() + + logger.info("Updating and processing all datasets in the project") + bd.projects.set_current(bd.projects.current) + + for db_name in bd.databases: + self.update_database_activity_types(db_name) + + # set the bw25 flag in the project dataset + bd.projects.dataset.data["25"] = True + bd.projects.dataset.save() + + # reloading project to ensure all changes are applied + bd.projects.set_current(bd.projects.current) + + @classmethod + def pre_process_methods(cls): + logger.info("Pre-processing methods for migration to bw25") + data = {m: bd.Method(m).load() for m in bd.methods} + df = pd.DataFrame([(k, v[0][0], v[0][1], v[1]) + for k, values in data.items() for v in values + if isinstance(v[0], tuple) and len(v) == 2 and len(v[0]) == 2], + columns=["method", "database", "code", "value"]) + + df = df.merge(app.metadata.dataframe["id"], left_on=["database", "code"], right_index=True) + + app.signals.method.blockSignals(True) + app.signals.meta.blockSignals(True) + + for name in tqdm(df["method"].unique(), desc="Pre-processing methods", unit="method", total=len(df["method"].unique())): + method_df = df[df["method"] == name][["id", "value"]] + method_list = list(method_df.itertuples(index=False, name=None)) + bd.Method(name).write(method_list, process=False) + + app.signals.method.blockSignals(False) + app.signals.meta.blockSignals(False) + + return + + @classmethod + def update_database_activity_types(cls, db_name: str): + database = bd.Database(db_name) + write = False + + if not isinstance(database, bd.backends.SQLiteBackend): + return + + logger.info(f"Updating activity types in {db_name}") + raw = database.load() + + for key, ds in tqdm(raw.items(), desc=f"Updating activity types in {db_name}", unit="activity", total=len(raw)): + if cls.activity_is_processwithreferenceproduct(ds): + write = True + ds["type"] = "processwithreferenceproduct" + + if write: + database.write(raw) + + @staticmethod + def activity_is_processwithreferenceproduct(ds: dict) -> bool: + production = [exc for exc in ds.get("exchanges", []) if exc.get("type") == "production"] + return ( + ds.get("type") in ["process", "processwithreferenceproduct"] and + ( + len(production) == 0 or + production[0].get("input") == (ds["database"], ds["code"]) + ) + ) + + + diff --git a/activity_browser/app/actions/project/project_new.py b/activity_browser/app/actions/project/project_new.py new file mode 100644 index 000000000..52ad7d26a --- /dev/null +++ b/activity_browser/app/actions/project/project_new.py @@ -0,0 +1,49 @@ +from qtpy import QtWidgets + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui.icons import qicons + + +class ProjectNew(ABAction): + """ + Prompts the user to create a new project by entering a name. If the name is valid and not already in use, + a new project is created and set as the current project. + + Steps: + - Open a dialog to get the new project name from the user. + - Return if the user cancels or provides an empty name. + - Check if the name already exists and show an error message if it does. + - Create a new project with the given name and set it as the current project. + + Raises: + None + """ + + icon = qicons.add + text = "New project" + tool_tip = "Make a new project" + + @staticmethod + @exception_dialogs + def run(): + name, ok = QtWidgets.QInputDialog.getText( + app.main_window, + "Create new project", + "Name of new project:" + " " * 25, + ) + + if not ok or not name: + return + + if name in bd.projects: + QtWidgets.QMessageBox.information( + app.main_window, + "Not possible.", + "A project with this name already exists.", + ) + return + + bd.projects.create_project(name) + bd.projects.set_current(name, update=False) diff --git a/activity_browser/app/actions/project/project_new_remote.py b/activity_browser/app/actions/project/project_new_remote.py new file mode 100644 index 000000000..d61923592 --- /dev/null +++ b/activity_browser/app/actions/project/project_new_remote.py @@ -0,0 +1,65 @@ +from qtpy import QtWidgets + +import bw2data as bd + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod.bw2io import remote +from activity_browser.ui.icons import qicons +from activity_browser.ui.core.threading import ABThread + +from .project_switch import ProjectSwitch + + +class ProjectNewRemote(ABAction): + """ + ABAction to create a new project from a remote template. + """ + + icon = qicons.add + text = "New project from remote" + tool_tip = "Make a new project from remote template" + + @staticmethod + @exception_dialogs + def run(project_key: str): + name, ok = QtWidgets.QInputDialog.getText( + app.main_window, + "Create project from remote", + "Name of new project:" + " " * 25, + ) + + if not ok or not name: + return + + if name in bd.projects: + QtWidgets.QMessageBox.information( + app.main_window, + "Not possible.", + "A project with this name already exists.", + ) + return + + thread = InstallThread(app.application) + thread.start(project_key, name) + + dialog = MigrateDialog(app.main_window) + dialog.show() + + thread.finished.connect(dialog.close) + thread.finished.connect(lambda: ProjectSwitch.run(name)) + + +class MigrateDialog(QtWidgets.QProgressDialog): + def __init__(self, parent): + super().__init__(parent) + self.setWindowTitle("Installing project") + self.setLabelText("Restoring project from template, this may take a while...") + self.setRange(0, 0) + self.setCancelButton(None) + + +class InstallThread(ABThread): + def run_safely(self, project_key: str, name: str): + remote.install_project(project_key, name) + diff --git a/activity_browser/app/actions/project/project_new_template.py b/activity_browser/app/actions/project/project_new_template.py new file mode 100644 index 000000000..936d2d8db --- /dev/null +++ b/activity_browser/app/actions/project/project_new_template.py @@ -0,0 +1,87 @@ +from qtpy import QtWidgets, QtCore +from loguru import logger + +import bw2data as bd +from bw2io import backup + +from activity_browser import app +from activity_browser.bwutils.commontasks import get_templates +from activity_browser.app.actions.base import ABAction, exception_dialogs + +from activity_browser.ui.core.threading import ABThread +from activity_browser.ui.icons import qicons + + + + +class ProjectNewFromTemplate(ABAction): + """ + ABAction to create a new project from a remote template. + """ + + icon = qicons.add + text = "New project from remote" + tool_tip = "Make a new project from remote template" + + @staticmethod + @exception_dialogs + def run(template_key: str): + + if template_key not in get_templates(): + raise ValueError(f"Template key '{template_key}' not found.") + + name, ok = QtWidgets.QInputDialog.getText( + app.main_window, + "Create project from template", + "Name of new project:" + " " * 25, + ) + + if not ok or not name: + return + + if name in bd.projects: + QtWidgets.QMessageBox.information( + app.main_window, + "Not possible.", + "A project with this name already exists.", + ) + return + + # setup dialog + progress = QtWidgets.QProgressDialog( + parent=app.main_window, + labelText="Creating project from template", + maximum=0 + ) + progress.setCancelButton(None) + progress.setWindowTitle("Creating project from template") + progress.setWindowFlag(QtCore.Qt.WindowContextHelpButtonHint, False) + progress.setWindowFlag(QtCore.Qt.WindowCloseButtonHint, False) + progress.findChild(QtWidgets.QProgressBar).setTextVisible(False) + progress.resize(400, 100) + progress.show() + + # setup the import + thread = ImportThread(app.application) + setattr(thread, "path", get_templates()[template_key]) + setattr(thread, "project_name", name) + + thread.finished.connect(lambda: progress.deleteLater()) + thread.finished.connect(lambda: bd.projects.set_current(name, update=False)) + + # start the import + thread.start() + + +class ImportThread(ABThread): + path: str + project_name: str + + def run_safely(self): + logger.debug('Creating project from template:' + f'\nPATH: {self.path}' + f'\nNAME: {self.project_name}') + backup.restore_project_directory(fp=self.path, project_name=self.project_name) + logger.info(f"Project `{self.project_name}` created.") + + diff --git a/activity_browser/app/actions/project/project_remote_import.py b/activity_browser/app/actions/project/project_remote_import.py new file mode 100644 index 000000000..924b8cc16 --- /dev/null +++ b/activity_browser/app/actions/project/project_remote_import.py @@ -0,0 +1,318 @@ +from typing import Any +from urllib.parse import urljoin +from loguru import logger + +from qtpy import QtWidgets, QtCore + +from bw2io import install_project +import requests + +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.mod import bw2data as bd +from activity_browser.ui import icons, widgets + + + + +class CatalogueModel(QtCore.QAbstractTableModel): + def __init__(self): + super().__init__() + self._data = [] + self._sorted = [key for key in self._data] + + def populate(self, data: dict) -> None: + self._data = data + self._sorted = [key for key in self._data] + + def data(self, index: int, role: int): + if role == QtCore.Qt.DisplayRole: + return self._sorted[index.row()] + elif role == QtCore.Qt.ToolTipRole: + return self._data[self._sorted[index.row()]] + + def rowCount(self, index: int) -> int: + return len(self._data) + + def columnCount(self, index: int) -> int: + return 1 + + def headerData(self, section:int, orientation:QtCore.Qt.Orientation, role: int=QtCore.Qt.DisplayRole) -> Any: + if orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole: + return "Available projects" + return None + + +class CatalogueTable(QtWidgets.QTableView): + def __init__(self, parent=None): + super().__init__(parent) + self.setVerticalScrollMode(QtWidgets.QTableView.ScrollPerPixel) + self.setHorizontalScrollMode(QtWidgets.QTableView.ScrollPerPixel) + self.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Expanding) + + self.setWordWrap(True) + self.setAlternatingRowColors(True) + self.setSortingEnabled(False) + + self.model = CatalogueModel() + self.setModel(self.model) + + self.horizontalHeader().setStretchLastSection(True) + self.horizontalHeader().setHighlightSections(False) + self.horizontalHeader().setDefaultAlignment(QtCore.Qt.AlignLeft) + self.verticalHeader().setVisible(False) + self.setTabKeyNavigation(False) + self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection) + + self.table_name = "Available projects" + # Make sure the selected projects is still visible after the focus leaves the table + self.setStyleSheet("QTableView:!active {selection-background-color: lightgray;}") + + def populate(self, data: dict) -> None: + self.model.populate(data) + self.model.layoutChanged.emit() + + +class ProjectRemoteImportWindow(QtWidgets.QDialog): + def __init__(self): + super().__init__() + self.setWindowTitle("Import project from remote server") + self.setSizePolicy( + QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + ) + + layout = QtWidgets.QVBoxLayout() + dialog_spacing = 8 + + remote_url_layout = QtWidgets.QHBoxLayout() + remote_url_layout.setAlignment(QtCore.Qt.AlignLeft) + remote_url_layout.addWidget(widgets.ABLabel.demiBold("Remote URL:")) + self.remote_url_path = QtWidgets.QLineEdit() + self.remote_url_path.setText("https://files.brightway.dev/") + self.remote_url_path.textChanged.connect(self._handle_url_changed) + remote_url_layout.addWidget(self.remote_url_path) + layout.addLayout(remote_url_layout) + + remote_catalogue_layout = QtWidgets.QHBoxLayout() + remote_catalogue_layout.setAlignment(QtCore.Qt.AlignLeft) + remote_catalogue_layout.addWidget(widgets.ABLabel.demiBold("Catalogue file:")) + self.remote_catalogue = QtWidgets.QLineEdit() + self.remote_catalogue.setText("projects-config.json") + self.remote_catalogue.textChanged.connect(self._handle_url_changed) + remote_catalogue_layout.addWidget(self.remote_catalogue) + layout.addLayout(remote_catalogue_layout) + layout.addSpacing(dialog_spacing) + + refresh_button_layout = QtWidgets.QHBoxLayout() + self.refresh_button = QtWidgets.QPushButton("Download catalogue") + refresh_button_layout.addWidget(self.refresh_button) + self.refresh_button.clicked.connect(self._populate_table) + layout.addLayout(refresh_button_layout) + layout.addSpacing(dialog_spacing) + + self.table = CatalogueTable() + self.table.selectionModel().selectionChanged.connect( + self._handle_table_selection_changed + ) + layout.addWidget(self.table) + layout.addSpacing(dialog_spacing) + + project_name_layout = QtWidgets.QHBoxLayout() + project_name_layout.setAlignment(QtCore.Qt.AlignLeft) + project_name_layout.addWidget(widgets.ABLabel.demiBold("Project name:")) + self.project_name = QtWidgets.QLineEdit() + self.project_name.setText("") + self.project_name.textChanged.connect(self._handle_project_name_changed) + project_name_layout.addWidget(self.project_name) + layout.addLayout(project_name_layout) + + self._overwrite_checkbox = QtWidgets.QCheckBox("Overwrite existing project") + self._overwrite_checkbox.clicked.connect(self._handle_overwrite_clicked) + layout.addWidget(self._overwrite_checkbox) + + self._activate_project_checkbox = QtWidgets.QCheckBox("Activate project after import") + self._activate_project_checkbox.setChecked(True) + layout.addWidget(self._activate_project_checkbox) + + import_button_layout = QtWidgets.QHBoxLayout() + self.import_button = QtWidgets.QPushButton("Create project") + import_button_layout.addWidget(self.import_button) + self.import_button.clicked.connect(self._import_project) + layout.addLayout(import_button_layout) + self._message_label = QtWidgets.QLabel("") + layout.addWidget(self._message_label) + + self.setLayout(layout) + self._last_url = "" + # Initialize the dialog + self._populate_table() + + def _reset_dialog(self): + self.table.setEnabled(False) + self.table.populate(dict()) + self.table.selectionModel().clearSelection() + self.project_name.setEnabled(False) + self.project_name.setPlaceholderText("") + self._overwrite_checkbox.setEnabled(False) + self._overwrite_checkbox.setChecked(False) + self._activate_project_checkbox.setEnabled(False) + self.import_button.setEnabled(False) + self._message_label.setText("") + + def url(self) -> str: + return urljoin(self.remote_url_path.text(), self.remote_catalogue.text()) + + def _populate_table(self): + self._reset_dialog() + try: + self.refresh_button.setText("Downloading...") + self.refresh_button.setEnabled(False) + self.repaint() + self.setCursor(QtCore.Qt.WaitCursor) + self._last_url = self.url() + data = requests.get(self._last_url).json() + self.table.setEnabled(True) + self.project_name.setEnabled(True) + self._activate_project_checkbox.setEnabled(True) + success = True + except: + data = {"Error loading catalogue": None} + self._message_label.setText("Load a valid catalogue") + success = False + + self.refresh_button.setText("Download catalogue") + self.refresh_button.setEnabled(True) + self.setCursor(QtCore.Qt.ArrowCursor) + self.table.populate(data) + if success: + self._check_project_already_exists() + + def _selected_project_name(self) -> str: + """Return the selected project name.""" + selection = self.table.selectedIndexes() + if selection: + selected_item: QtCore.QModelIndex = selection[0] + if selected_item.isValid(): + return selected_item.data() + return "" + + def _project_name(self) -> str: + """Return the user typed project name or, if empty, the selected one.""" + if self.project_name.text() == "": + return self._selected_project_name() + return self.project_name.text() + + def _handle_url_changed(self): + if self._last_url != self.url(): + self._reset_dialog() + self._message_label.setText("Load a valid catalogue") + + def _handle_table_selection_changed(self): + """ + Update the UI when the table selection changes. + + We set the currently selected project name as placeholder text, + to hint that it can be changed, or will be used as default. + """ + self.project_name.setPlaceholderText(self._selected_project_name()) + self._check_project_already_exists() + + def _handle_project_name_changed(self): + self._check_project_already_exists() + + def _unique_project_selection_update(self, selection_valid: bool): + """ + Update the UI when the selection in the table changes and the + project name is unique. + """ + if selection_valid: + self.import_button.setEnabled(True) + self._message_label.setText("") + else: + self.import_button.setEnabled(False) + self._message_label.setText("Select a project to import") + + def _duplicate_project_checkbox_update(self): + """ + Update the UI when the overwrite checkbox state changes and the + project name is not unique. + + Use the actual state of the checkbox, because it is not + called only from the checkbox click event. + """ + if self._overwrite_checkbox.isChecked(): + self.import_button.setEnabled(True) + self._message_label.setText("") + else: + self.import_button.setEnabled(False) + self._message_label.setText("Project name already exists") + + def _handle_overwrite_clicked(self): + self._duplicate_project_checkbox_update() + + def _check_project_already_exists(self): + """ + Update the overwrite checkbox and import button based on the project name. + + If the project already exists, it can only be imported with the + overwrite flag set. To make sure the user does not import it accidentaly, + the flag is reset every time the selected project or the project name changes. + """ + self._overwrite_checkbox.setChecked(False) + if self._project_name() in bd.projects: + self._overwrite_checkbox.setEnabled(True) + self._duplicate_project_checkbox_update() + else: + self._overwrite_checkbox.setEnabled(False) + # Disable the import if there is no selection + self._unique_project_selection_update(len(self.table.selectedIndexes()) > 0) + + def _import_project(self): + """ + Import the selected project with the new name. + It is checked with the UI flow, that there is a catalogue loaded, + a project to import selected and a unique name provided or the overwrite + flag is set. + """ + original_name = self._selected_project_name() + new_name = self._project_name() + if original_name and new_name: + logger.info(f"Importing project with name {new_name} " + f"(original name {original_name})") + self.import_button.setText("Creating project...") + self.import_button.setEnabled(False) + self.repaint() + self.setCursor(QtCore.Qt.WaitCursor) + + install_project( + original_name, + new_name, + url=self.remote_url_path.text(), + overwrite_existing=self._overwrite_checkbox.isChecked() + ) + if self._activate_project_checkbox.isChecked(): + bd.projects.set_current(new_name, update=False) + self.setCursor(QtCore.Qt.ArrowCursor) + self.accept() + else: + logger.error(f"Project name ({new_name}) or import name ({original_name}) is not valid.") + + + +class ProjectRemoteImport(ABAction): + """ + ABAction to download a project file from a remote server. + Allows for customization of server URL, created project name, and whether or not to overwrite existing projects. + """ + + icon = icons.qicons.import_db + text = "Import remote project" + tool_tip = "Import a project file from a remote server" + + @staticmethod + @exception_dialogs + def run(): + window = ProjectRemoteImportWindow() + window.adjustSize() + window.exec_() diff --git a/activity_browser/app/actions/project/project_switch.py b/activity_browser/app/actions/project/project_switch.py new file mode 100644 index 000000000..9e3268b59 --- /dev/null +++ b/activity_browser/app/actions/project/project_switch.py @@ -0,0 +1,118 @@ +import datetime +from loguru import logger + +from qtpy import QtWidgets, QtCore + +import bw2data as bd + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui.core.application import global_shortcut + +from .project_migrate25 import ProjectMigrate25 + + + + +class ProjectSwitch(ABAction): + """ + Switch to a specified Brightway2 project. + + This method compares the given project name with the currently active project. + If the specified project is different, it switches to the new project, updates + the last opened timestamp, and logs the change. If the project is not Brightway25 + compatible, a warning is displayed. If the specified project is already active, + no action is taken. + + Args: + project_name (str): The name of the project to switch to. + + Logs: + Warning: If the project is not Brightway25 compatible. + Info: When the project is successfully switched. + Debug: If the specified project is already the current project. + """ + + text = "Switch project" + tool_tip = "Switch the project" + + @staticmethod + @exception_dialogs + def run(project_name: str, reload: bool = False): + # compare the new to the current project name and switch to the new one if the two are not the same + if project_name == bd.projects.current and not reload: + logger.debug(f"Brightway2 already selected: {project_name}") + return + + dialog = ProjectChangeDialog(project_name, reload, app.main_window) + dialog.show() + app.application.processEvents() + + # switch to the new project, don't auto update to brightway25 + bd.projects.set_current(project_name, update=False) + + if not bd.projects.twofive: + logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") + ProjectSwitch.set_warning_bar() + + logger.info(f"Brightway2 current project: {project_name}") + + # update the last opened timestamp + bd.projects.dataset.data["last_opened"] = datetime.datetime.now().isoformat() + bd.projects.dataset.save() + + app.application.processEvents() + dialog.close() + + @staticmethod + def set_warning_bar(): + app.main_window.addToolBar(ProjectWarningBar()) + + @global_shortcut("F5") + @staticmethod + def reload_project(): + ProjectSwitch.run(bd.projects.current, reload=True) + + +class ProjectChangeDialog(QtWidgets.QDialog): + def __init__(self, project_name: str, reload: bool, parent=None): + super().__init__(parent, QtCore.Qt.WindowTitleHint) + + title = "Reloading project" if reload else "Switching project" + subtitle = f"Reloading project: {project_name}" if reload else f"Switching to project: {project_name}" + + self.setWindowTitle(title) + self.setModal(True) + + self.label = QtWidgets.QLabel(subtitle, self) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.label) + self.setLayout(layout) + + +class ProjectWarningBar(QtWidgets.QToolBar): + def __init__(self, parent=None): + super().__init__(parent) + self.setMovable(False) + + warning_label = QtWidgets.QLabel(" This project is not Brightway25 compatible. ") + height = warning_label.minimumSizeHint().height() + + warning_icon = QtWidgets.QLabel(self) + qicon = app.application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) + pixmap = qicon.pixmap(height, height) + warning_icon.setPixmap(pixmap) + + migrate_label = QtWidgets.QLabel("Migrate project now") + migrate_label.mouseReleaseEvent = lambda x: ProjectMigrate25.run(bd.projects.current) + + self.addWidget(warning_icon) + self.addWidget(warning_label) + self.addWidget(migrate_label) + + app.signals.project.changed.connect(self.deleteLater) + + def contextMenuEvent(self, event): + return None + diff --git a/activity_browser/app/actions/pyside_upgrade.py b/activity_browser/app/actions/pyside_upgrade.py new file mode 100644 index 000000000..1ed892b77 --- /dev/null +++ b/activity_browser/app/actions/pyside_upgrade.py @@ -0,0 +1,102 @@ +import qtpy +import os +import sys +import subprocess +import time + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.ui import icons + +from qtpy import QtWidgets +from qtpy.QtCore import Signal, SignalInstance + +from activity_browser.ui.core import threading + + +class PysideUpgrade(ABAction): + """ + ABAction to install PySide6 through PyPI/pip. Installs PySide6, sets the environment variable for QtPy to use + PySide6 and then restarts the Activity Browser through a subprocess. + """ + + icon = icons.qicons.forward + text = "Upgrade installation to PySide6" + + @classmethod + @exception_dialogs + def run(cls): + + # slot definition to update the progress dialog with thread updates + def update_dialog_slot(progress: int, label: str): + dialog.setValue(progress) + dialog.setLabelText(label) + + assert not qtpy.PYSIDE6, "Already running PySide6" + assert cls.in_conda(), "Not inside a Conda environment" + + # setup a progress dialog to show the user we're doing something + dialog = QtWidgets.QProgressDialog(app.main_window) + dialog.setWindowTitle("Upgrading GUI back-end") + dialog.setMaximum(0) + dialog.setCancelButton(None) + + # messages can get quite long, so enable word-wrapping + lbl = dialog.findChild(QtWidgets.QLabel) + lbl.setWordWrap(True) + + # initialize thread and connect signals + thread = PySideUpgradeThread(app.application) + thread.status.connect(update_dialog_slot) + thread.exit.connect(sys.exit) + + thread.start() + dialog.exec_() + + @staticmethod + def in_conda() -> bool: + """Returns true when the current shell is in a Conda environment.""" + return bool(os.environ.get("CONDA_DEFAULT_ENV", False)) + + +class PySideUpgradeThread(threading.ABThread): + exit: SignalInstance = Signal() + + def run_safely(self): + self.pip_installation() + self.restart() + + def pip_installation(self): + """ + Install PySide6 from PyPI using a subprocess.Popen call + """ + self.status.emit(0, "Installing PySide6 through pip") + + # open subprocess that installs PySide6 + process = subprocess.Popen(["pip", "install", "pyside6"], stdout=subprocess.PIPE) + + while process.poll() is None: # block until the subprocess is finished + # format stdout + line = process.stdout.readline().decode().strip() + if not line: + continue + + # redirect stdout to both console and progress dialog + print(line) + self.status.emit(0, line) + + assert process.returncode == 0, "Failed to install PySide6" + + def restart(self): + """ + Restarts the Activity Browser through a subprocess. Sleeps 5 seconds to allow the user to register + the restart. + """ + self.status.emit(0, "Restarting the Activity Browser") + subprocess.Popen(["python", "-c", "import activity_browser; activity_browser.run_activity_browser()"]) + time.sleep(5) + + # signal restart through the exit signal as sys.exit needs to be called in the main thread. + self.exit.emit() + + diff --git a/activity_browser/app/actions/save_parameters_to_excel.py b/activity_browser/app/actions/save_parameters_to_excel.py new file mode 100644 index 000000000..50543d9cb --- /dev/null +++ b/activity_browser/app/actions/save_parameters_to_excel.py @@ -0,0 +1,39 @@ +import os + +import pandas as pd + +from qtpy import QtWidgets + +from activity_browser.app import application +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.utils import Parameters + + +class SaveParametersToExcel(ABAction): + """ + ABAction to export database(s) to Excel format (.xlsx). + """ + text = "Save parameters to Excel (.xlsx)" + tool_tip = "Save parameters to Excel format" + + @classmethod + @exception_dialogs + def run(cls, file_path: str = None): + if file_path is None: + suggestion = os.path.expanduser("~/parameters.xlsx") + + file_path, _ = QtWidgets.QFileDialog.getSaveFileName( + parent=application.main_window, + caption=f'Export parameters to Excel', + dir=suggestion, + filter='Excel spreadsheet (*.xlsx);; All files (*.*)' + ) + + if not file_path: + return + + data = [p[:3] for p in Parameters.from_bw_parameters()] + df = pd.DataFrame(data, columns=["Name", "Group", "default"]).set_index("Name") + df.to_excel(file_path) + + os.startfile(file_path) diff --git a/activity_browser/app/actions/tools/bw2io/tools_bw2io_migrations.py b/activity_browser/app/actions/tools/bw2io/tools_bw2io_migrations.py new file mode 100644 index 000000000..70e214027 --- /dev/null +++ b/activity_browser/app/actions/tools/bw2io/tools_bw2io_migrations.py @@ -0,0 +1,19 @@ +from activity_browser.app.actions.base import ABAction, exception_dialogs + +from activity_browser.ui.icons import qicons + +from activity_browser.mod.bw2io.migrations import ab_create_core_migrations + + +class ToolsBW2IOCreateMigrations(ABAction): + """ + ABAction to install default migrations from bw2io + """ + + icon = qicons.import_db + text = "Install default bw2io migrations" + + @staticmethod + @exception_dialogs + def run(): + ab_create_core_migrations() diff --git a/activity_browser/app/dialogs/README.md b/activity_browser/app/dialogs/README.md new file mode 100644 index 000000000..95087a8d4 --- /dev/null +++ b/activity_browser/app/dialogs/README.md @@ -0,0 +1,13 @@ +# dialogs + +Dialog windows for user interactions throughout Activity Browser. + +## Overview + +This directory contains modal and non-modal dialog windows used for various user interactions such as data entry, configuration, selection, and information display. Dialogs in the app directory are there because they are tightly integrated with Brightway2 or depend on the application for other reasons. + +- Generally, action specific dialogs are located alongside the corresponding action in the `actions/` directory. +- Dialogs than can be applied more widely and are not intimately tied with either actions or Brightway2 are located in the `ui/dialogs/` directory. +- Only if the above two locations are not appropriate should a dialog be placed here. + +What qualifies to be put in this directory is somewhat subjective, but the guiding principle is that these dialogs are core to the functioning of Activity Browser and are not easily reusable outside of it. \ No newline at end of file diff --git a/activity_browser/app/dialogs/__init__.py b/activity_browser/app/dialogs/__init__.py new file mode 100644 index 000000000..5a337f238 --- /dev/null +++ b/activity_browser/app/dialogs/__init__.py @@ -0,0 +1,3 @@ +from .import_preview_dialog import ImportPreviewDialog +from .node_select_dialog import NodeSelectDialog +from .database_select_dialog import DatabaseSelectDialog diff --git a/activity_browser/app/dialogs/database_select_dialog.py b/activity_browser/app/dialogs/database_select_dialog.py new file mode 100644 index 000000000..8639ef2c2 --- /dev/null +++ b/activity_browser/app/dialogs/database_select_dialog.py @@ -0,0 +1,35 @@ +from typing import List + +from qtpy import QtWidgets + + +class DatabaseSelectDialog(QtWidgets.QDialog): + """Dialog to select one or more databases for export.""" + + def __init__(self, parent=None, databases=None, title="Select databases"): + super().__init__(parent=parent) + self.setWindowTitle(title) + self.setModal(True) + self.resize(400, 300) + + layout = QtWidgets.QVBoxLayout(self) + + self.db_list_widget = QtWidgets.QListWidget(self) + self.db_list_widget.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) + for db_name in databases: + item = QtWidgets.QListWidgetItem(db_name) + self.db_list_widget.addItem(item) + layout.addWidget(self.db_list_widget) + + button_box = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, + parent=self + ) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def get_selected_databases(self) -> List[str]: + """Return the list of selected database names.""" + selected_items = self.db_list_widget.selectedItems() + return [item.text() for item in selected_items] diff --git a/activity_browser/app/dialogs/import_preview_dialog/__init__.py b/activity_browser/app/dialogs/import_preview_dialog/__init__.py new file mode 100644 index 000000000..885add2a0 --- /dev/null +++ b/activity_browser/app/dialogs/import_preview_dialog/__init__.py @@ -0,0 +1 @@ +from .import_preview_dialog import ImportPreviewDialog \ No newline at end of file diff --git a/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py new file mode 100644 index 000000000..203863cfa --- /dev/null +++ b/activity_browser/app/dialogs/import_preview_dialog/edge_tab.py @@ -0,0 +1,253 @@ +from PySide6.QtCore import QModelIndex +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt + +from loguru import logger + +import pandas as pd + +from bw2io.importers.base_lci import LCIImporter + +from activity_browser.ui import widgets, core, delegates, icons + +from ..node_select_dialog import NodeSelectDialog + + +class ImportPreviewEdgeTab(QtWidgets.QWidget): + standardEdgeColumns = ["linked", "type", "amount", "unit", "input", "name", "location", "database", "formula"] + + def __init__(self, importer: LCIImporter, parent=None): + super().__init__(parent) + self.importer = importer + self.simple = True + self.old_links: dict[tuple[int, int], tuple[str, str] | None] = {} + + layout = QtWidgets.QVBoxLayout(self) + + self.edge_model = ImportPreviewEdgeModel(parent=self) + self.edge_model.set_dataframe(self.build_df()) + self.edge_model.group(["_node"]) + + self.edge_view = ImportPreviewEdgeView(importer, self) + self.edge_view.setUniformRowHeights(False) + self.edge_view.setModel(self.edge_model) + self.edge_view.setColumnWidth(0, 0) + + # Create simple/detailed view toggle + self.view_toggle = QtWidgets.QCheckBox("Details") + self.view_toggle.setChecked(not self.simple) + self.view_toggle.setToolTip("Toggle between simple and detailed view") + self.view_toggle.checkStateChanged.connect(self.on_mode_switch) + + # Create top bar with toggle + top_bar = QtWidgets.QHBoxLayout() + top_bar.addStretch() + top_bar.addWidget(self.view_toggle) + + layout.addLayout(top_bar) + layout.addWidget(self.edge_view) + + self.sync() + + def sync(self): + """Synchronize the view based on simple/detailed mode.""" + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.edge_view.header().setHidden(self.simple) + self.edge_view.viewport().setBackgroundRole( + QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) + self.edge_view.setFrameShape( + QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) + + df = self.build_df() + + if self.simple and "_exc" in df.columns: + df.rename(columns={"_exc": "exc"}, inplace=True) + elif not self.simple and "node" in df.columns: + df.rename(columns={"exc": "_exc"}, inplace=True) + + self.edge_model.update_dataframe(df) + + for col in self.edge_model.columns(): + if col == "index": + continue + index = self.edge_model.columns().index(col) + + hidden = (self.simple and not col == "exc") or (not self.simple and col == "exc") + self.edge_view.setColumnHidden(index, hidden) + + def build_df(self): + + exchanges = [] + for node_i, node in enumerate(self.importer.data): + summary = [ + node.get("name"), + node.get("location"), + node.get("database"), + node.get("code"), + ] + summary = " | ".join([str(part) for part in summary if part]) + + for exc_i, exc in enumerate(node.get("exchanges", [])): + exc = exc.copy() + exc["_node"] = summary + exc["_location"] = (node_i, exc_i) + exchanges.append(exc) + + df = pd.DataFrame(exchanges) + for col in [col for col in self.standardEdgeColumns if col not in df.columns]: + df[col] = None + df["exc"] = None + + def determine_link_status(row): + input_val = row["input"] + location = row["_location"] + + if not isinstance(input_val, tuple): + return "unlinked" + elif location in self.old_links: + return "relinked" + else: + return "linked" + + df["linked"] = df.apply(determine_link_status, axis=1) + + return df + + def on_mode_switch(self, check: Qt.CheckState): + """Handle the mode switch between simple and detailed view.""" + self.simple = check == Qt.CheckState.Unchecked + self.sync() + + def relink_selected_exchanges(self): + """Open a dialog to link selected exchanges to existing nodes.""" + exchange_locations = self.edge_view.selected_exchanges + if not exchange_locations: + return + + dialog = NodeSelectDialog(parent=self) + if not dialog.exec() == QtWidgets.QDialog.DialogCode.Accepted: + return + + selected_node = dialog.get_selected_node() + + for loc in exchange_locations: + node_i, exc_i = loc + + if loc not in self.old_links: + self.old_links[loc] = self.importer.data[node_i]["exchanges"][exc_i].get("input") + + self.importer.data[node_i]["exchanges"][exc_i]["input"] = (selected_node["database"], selected_node["code"]) + + self.sync() + + +class ShiftedCardDelegate(delegates.CardDelegate): + """ + Delegate that shifts the card content to the left to compensate for indentation. + """ + def paint(self, painter, option, index): + # Adjust the rect to shift content left, compensating for indentation + adjusted_option = QtWidgets.QStyleOptionViewItem(option) + adjusted_option.rect.adjust(-28, 0, 0, 0) + + # Call the original paint with adjusted rect + super().paint(painter, adjusted_option, index) + + +class ImportPreviewEdgeView(widgets.ABTreeView): + """View for displaying import preview nodes.""" + + defaultColumnDelegates = { + "exc": ShiftedCardDelegate, + } + + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m, p: m.callback( + text="Link exchange" if len(p.selected_exchanges) == 1 else "Link exchanges", + func=p.tab.relink_selected_exchanges, + ) + ] + + def __init__(self, importer: LCIImporter, tab: ImportPreviewEdgeTab): + super().__init__(tab) + self.importer = importer + self.old_links = {} + self.tab = tab + + @property + def selected_exchanges(self): + """ + Returns a list of selected exchange locations as (node_index, exchange_index) tuples. These can be used to + identify and manipulate the selected exchanges in the importer's data, which is a list of lists. + """ + return list(set([self.model().get(index, "_location") for index in self.selectedIndexes()])) + + +class ImportPreviewEdgeModel(core.ABTreeModel): + """Model for import preview nodes with node delegate support.""" + + def displayData(self, index: QtCore.QModelIndex) -> any: + if not index.isValid(): + return None + + column_name = self.columns()[index.column()] + if not column_name == "exc" or self.row(index) is None: + return super().displayData(index) + + row_data = self.row(index).copy() + row_data.dropna(inplace=True) + + # Build the card information + title = row_data.get('reference product') or row_data.get('name') + subtitle = row_data.get('name') + detail = f"{row_data.get('amount')} {row_data.get('unit')}" + + # Build categories list from unit, location + categories = [] + if row_data.get("type"): + categories.append(str(row_data.get("type"))) + if row_data.get("location"): + categories.append(str(row_data.get("location"))) + if row_data.get("categories"): + categories.append(", ".join([str(cat) for cat in row_data.get("categories")])) + if row_data.get("database"): + categories.append(str(row_data.get("database"))) + + return { + "title": title, + "subtitle": subtitle, + "categories": categories if categories else None, + "detail": detail, + } + + + def decorationData(self, index: QModelIndex) -> QtGui.QIcon: + if not index.isValid(): + return icons.qicons.empty + + column_name = self.columns()[index.column()] + if not column_name in ["exc"]: + return super().decorationData(index) + + linked = self.get(index, "linked") + if linked == "linked": + return icons.qicons.link + elif linked == "unlinked": + return icons.qicons.unlink + elif linked == "relinked": + return icons.qicons.relink + return icons.qicons.empty + + def indexSelectable(self, index: QModelIndex) -> bool: + # Don't make the tree column selectable + if index.column() == 0: + return False + return True + + + + + + diff --git a/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py b/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py new file mode 100644 index 000000000..4c4eb3c0d --- /dev/null +++ b/activity_browser/app/dialogs/import_preview_dialog/import_preview_dialog.py @@ -0,0 +1,30 @@ +from qtpy import QtWidgets, QtCore, QtGui + +import pandas as pd + +from bw2io.importers.base_lci import LCIImporter + +from activity_browser.ui import widgets, core + +from .node_tab import ImportPreviewNodeTab +from .edge_tab import ImportPreviewEdgeTab + + +class ImportPreviewDialog(QtWidgets.QDialog): + def __init__(self, importer: LCIImporter, parent=None): + super().__init__(parent) + self.setWindowTitle("Import Preview") + self.resize(600, 400) + + self.importer = importer + self.tabs = QtWidgets.QTabWidget(self) + + self.node_tab = ImportPreviewNodeTab(importer, self) + self.edge_tab = ImportPreviewEdgeTab(importer, self) + + self.tabs.addTab(self.node_tab, "Nodes") + self.tabs.addTab(self.edge_tab, "Edges") + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.tabs) + self.setLayout(layout) diff --git a/activity_browser/app/dialogs/import_preview_dialog/node_tab.py b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py new file mode 100644 index 000000000..de6e72f02 --- /dev/null +++ b/activity_browser/app/dialogs/import_preview_dialog/node_tab.py @@ -0,0 +1,172 @@ +from PySide6.QtCore import QModelIndex +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt + +from loguru import logger + +import pandas as pd + +from bw2io.importers.base_lci import LCIImporter + +from activity_browser.ui import widgets, core, delegates, icons + + +class ImportPreviewNodeTab(QtWidgets.QWidget): + standardNodeColumns = ["type", "name", "product", "exchanges", "unlinked_exchanges", "location", "unit", "categories", "code", + "database"] + standardEdgeColumns = ["type", "amount", "unit", "input", "name", "location", "database", "formula"] + + def __init__(self, importer: LCIImporter, parent=None): + super().__init__(parent) + self.importer = importer + self.simple = True + + layout = QtWidgets.QVBoxLayout(self) + + self.node_model = ImportPreviewNodeModel(parent=self) + self.node_model.set_dataframe(self.build_df()) + + self.node_view = ImportPreviewNodeView(parent=self) + self.node_view.setModel(self.node_model) + + # Create simple/detailed view toggle + self.view_toggle = QtWidgets.QCheckBox("Details") + self.view_toggle.setChecked(not self.simple) + self.view_toggle.setToolTip("Toggle between simple and detailed view") + self.view_toggle.checkStateChanged.connect(self.on_mode_switch) + + # Create top bar with toggle + top_bar = QtWidgets.QHBoxLayout() + top_bar.addStretch() + top_bar.addWidget(self.view_toggle) + + layout.addLayout(top_bar) + layout.addWidget(self.node_view) + + self.sync() + + def sync(self): + """Synchronize the view based on simple/detailed mode.""" + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.node_view.header().setHidden(self.simple) + self.node_view.viewport().setBackgroundRole( + QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) + self.node_view.setFrameShape( + QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) + + df = self.node_model.df.copy() + if self.simple and "_node" in df.columns: + df.rename(columns={"_node": "node"}, inplace=True) + elif not self.simple and "node" in df.columns: + df.rename(columns={"node": "_node"}, inplace=True) + self.node_model.set_dataframe(df) + + for col in self.node_model.columns(): + if col == "index": + continue + index = self.node_model.columns().index(col) + + hidden = (self.simple and not col == "node") or (not self.simple and col == "node") + self.node_view.setColumnHidden(index, hidden) + + def build_df(self): + node_df = pd.DataFrame(self.importer.data) + for col in [col for col in self.standardNodeColumns if col not in node_df.columns]: + node_df[col] = None + + node_df["_exchanges"] = node_df["exchanges"] + node_df["unlinked_exchanges"] = node_df["exchanges"].apply( + lambda x: sum(1 for ex in x if not ex.get("input")) if isinstance(x, list) else 0 + ) + node_df["exchanges"] = node_df["exchanges"].apply(lambda x: len(x) if isinstance(x, list) else 0) + + node_df = node_df[ + self.standardNodeColumns + + [col for col in node_df.columns if col not in self.standardNodeColumns] + ] + node_df["_importer_index"] = range(len(node_df)) + + node_df["node"] = None + + return node_df + + def on_mode_switch(self, check: Qt.CheckState): + """Handle the mode switch between simple and detailed view.""" + self.simple = check == Qt.CheckState.Unchecked + self.sync() + + +class ImportPreviewNodeView(widgets.ABTreeView): + """View for displaying import preview nodes.""" + + defaultColumnDelegates = { + "node": delegates.CardDelegate, + } + + +class ImportPreviewNodeModel(core.ABTreeModel): + """Model for import preview nodes with node delegate support.""" + + def displayData(self, index: QtCore.QModelIndex) -> any: + if not index.isValid(): + return None + + column_name = self.columns()[index.column()] + if not column_name == "node": + return super().displayData(index) + + row_data = self.row(index).copy() + row_data.dropna(inplace=True) + + # Get the product or name for title + title = row_data.get("product") or row_data.get("name") + + # Build subtitle with type and database + if row_data.get("categories"): + subtitle = ", ".join([str(cat) for cat in row_data.get("categories")]) + elif row_data.get("product"): + subtitle = row_data.get("name") + else: + excs = row_data.get("exchanges") + unlinked = row_data.get("unlinked_exchanges") + nomination = "exchanges" if excs != 1 else "exchange" + + subtitle = f"{excs} {nomination}, {unlinked} unlinked" + + # Build categories list from unit, location + categories = [] + if row_data.get("unit"): + categories.append(str(row_data.get("unit"))) + if row_data.get("location"): + categories.append(str(row_data.get("location"))) + if row_data.get("database"): + categories.append(str(row_data.get("database"))) + + return { + "title": title, + "subtitle": subtitle, + "categories": categories if categories else None, + } + + + def decorationData(self, index: QModelIndex) -> QtGui.QIcon: + if not index.isValid(): + return icons.qicons.empty + + column_name = self.columns()[index.column()] + if not column_name in ["node", "type"]: + return super().decorationData(index) + + node_type = self.get(index, "type") + + if node_type == "product": + return icons.qicons.product + if node_type == "waste": + return icons.qicons.waste + if node_type == "processwithreferenceproduct": + return icons.qicons.processproduct + if node_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: + return icons.qicons.biosphere + return icons.qicons.process + diff --git a/activity_browser/app/dialogs/node_select_dialog.py b/activity_browser/app/dialogs/node_select_dialog.py new file mode 100644 index 000000000..ffc805af5 --- /dev/null +++ b/activity_browser/app/dialogs/node_select_dialog.py @@ -0,0 +1,196 @@ +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt +import pandas as pd + +from activity_browser.ui import widgets, core, delegates, icons +from activity_browser.app import metadata +from activity_browser.bwutils.commontasks import refresh_node + + +class NodeSelectDialog(QtWidgets.QDialog): + node_selected = QtCore.Signal(dict) + + def __init__(self, parent=None, drag_enabled=False): + super().__init__(parent) + + self.setWindowFlags( + QtCore.Qt.WindowType.Popup | + QtCore.Qt.WindowType.FramelessWindowHint + ) + self.setFixedWidth(400) + self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.Maximum) + + self.edit = widgets.ABLineEdit(self) + self.edit.setPlaceholderText("Enter text to search for a node") + self.edit.textChangedDebounce.connect(self.on_search) + + # Create model and tree view for results + self.model = NodeSearchModel(parent=self) + self.tree_view = NodeSearchView(self) + self.tree_view.setModel(self.model) + + self.tree_view.clicked.connect(self.accept) + self.tree_view.dragStarted.connect(self.on_drag_started) + self.tree_view.setDragEnabled(drag_enabled) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 0) + layout.addWidget(self.edit) + layout.addWidget(self.tree_view) + self.setLayout(layout) + + self.setFixedHeight(self.sizeHint().height()) + + def showEvent(self, event): + super().showEvent(event) + self.edit.setFocus() + + def on_search(self, text: str): + if not text.strip(): + # Clear results + self.model.set_dataframe(pd.DataFrame()) + self.tree_view.setFixedHeight(0) + self.adjustSize() + self.setFixedHeight(self.sizeHint().height()) + return + + # Search and get results + result_df = metadata.search(text) + result_df = result_df[0:10] if len(result_df) > 10 else result_df + + # Add a placeholder "node" column for the CardDelegate + result_df["node"] = None + + # Update model with search results + self.model.set_dataframe(result_df) + + # Adjust height based on results + if len(result_df) > 0: + self.tree_view.setFixedHeight(min(400, len(result_df) * 80 + 20)) + else: + self.tree_view.setFixedHeight(0) + + # Adjust dialog to minimum size + self.adjustSize() + self.setFixedHeight(self.sizeHint().height()) + + def on_drag_started(self): + """Handle when a drag operation is started""" + self.hide() # Close the dialog + + def get_selected_node(self): + """Return the currently selected node data""" + index = self.tree_view.currentIndex() + if not index.isValid(): + return None + node_id = self.model.get(index, "id") + if not node_id: + return None + return refresh_node(node_id) + + +class NodeSearchModel(core.ABTreeModel): + """Model for displaying search results in the node select dialog.""" + + def columns(self) -> list[str]: + return ["index", "node"] + + def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: + return True + + def displayData(self, index: QtCore.QModelIndex) -> any: + if not index.isValid(): + return None + + column_name = self.columns()[index.column()] + if not column_name == "node": + return super().displayData(index) + + row_data = self.row(index).copy() + row_data.dropna(inplace=True) + + # Get the product or name for title + title = row_data.get("product") or row_data.get("name") + + # Build subtitle with type and database + if row_data.get("categories"): + subtitle = ", ".join([str(cat) for cat in row_data.get("categories")]) + elif row_data.get("product"): + subtitle = row_data.get("name") + else: + subtitle = "" + + # Build categories list from unit, location + categories = [] + if row_data.get("unit"): + categories.append(str(row_data.get("unit"))) + if row_data.get("location"): + categories.append(str(row_data.get("location"))) + if row_data.get("database"): + categories.append(str(row_data.get("database"))) + + return { + "title": title, + "subtitle": subtitle, + "categories": categories if categories else None, + } + + def decorationData(self, index: QtCore.QModelIndex) -> QtGui.QIcon: + if not index.isValid(): + return icons.qicons.empty + + node_type = self.get(index, "type") + + if node_type == "product": + return icons.qicons.product + if node_type == "waste": + return icons.qicons.waste + if node_type == "processwithreferenceproduct": + return icons.qicons.processproduct + if node_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: + return icons.qicons.biosphere + return icons.qicons.process + + def mimeData(self, indices: list[QtCore.QModelIndex]): + """ + Returns the mime data for the given indices. + + Args: + indices (list[QtCore.QModelIndex]): The indices to get the mime data for. + + Returns: + core.ABMimeData: The mime data. + """ + data = core.ABMimeData() + keys = [self.row(index).get("key") for index in indices if index.isValid()] + keys = {key for key in keys if isinstance(key, tuple)} + data.setPickleData("application/bw-nodekeylist", list(keys)) + return data + + +class NodeSearchView(widgets.ABTreeView): + """Tree view for displaying node search results.""" + dragStarted: QtCore.SignalInstance = QtCore.Signal() + + defaultColumnDelegates = { + "node": delegates.CardDelegate, + } + + def __init__(self, parent: NodeSelectDialog): + super().__init__(parent) + self.setSelectionBehavior(widgets.ABTreeView.SelectionBehavior.SelectRows) + self.setSelectionMode(widgets.ABTreeView.SelectionMode.SingleSelection) + self.viewport().setBackgroundRole(QtGui.QPalette.ColorRole.Window) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) + + self.setHeaderHidden(True) + + self.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + self.setFixedHeight(0) + + + def startDrag(self, supportedActions: Qt.DropAction) -> None: + self.dragStarted.emit() + super().startDrag(supportedActions) + diff --git a/activity_browser/app/main.py b/activity_browser/app/main.py new file mode 100644 index 000000000..3c0e99629 --- /dev/null +++ b/activity_browser/app/main.py @@ -0,0 +1,282 @@ +from pathlib import Path +from loguru import logger + +from qtpy import QtCore, QtWidgets + +import bw2data as bd +from activity_browser import app +from activity_browser.ui import widgets + + +class MainWindow(QtWidgets.QMainWindow): + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + + def __init__(self, parent=None): + from activity_browser.app.menu_bar import MenuBar + + if self._initialized: + return + self._initialized = True + + super().__init__(parent) + + self.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) + self.setWindowTitle("Activity Browser") + self.setDockNestingEnabled(True) + + # Layout: extra items outside main layout + self.menu_bar = MenuBar(self) + self.setMenuBar(self.menu_bar) + + self.central_widget = widgets.CentralTabWidget(self) + self.central_widget.setTabsClosable(True) + self.setCentralWidget(self.central_widget) + + # Initialize all base pages upfront (name -> widget instance) + self.base_pages = {} + for page_name, page_class in app.pages.base_pages.items(): + page_instance = page_class() + page_instance.setObjectName(page_name) + self.base_pages[page_name] = page_instance + + # Connect tab close signal + self.central_widget.tabCloseRequested.connect(self._on_tab_close_requested) + + self.connect_signals() + self.destroyed.connect(lambda: logger.warning("MainWindow destroyed")) + + def event(self, event): + if event.type() == QtCore.QEvent.Type.DeferredDelete: + for page in self.base_pages.values(): + logger.debug(f"Destroying base page {page.__class__.__name__}: {id(page)}") + try: + page.deleteLater() + except RuntimeError: + # page already deleted + pass + return super().event(event) + + def sync(self): + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + self.sync_panes() + self.sync_pages() + + self.setWindowTitle(f"Activity Browser - {bd.projects.current}") + + def sync_panes(self): + self.clearPanes() + + dws = [] + + # Iterate through the default panes and add them as dock widgets + for pane_name, pane_class in app.panes.base_panes.items(): + pane = pane_class(parent=self) + dockwidget = pane.getDockWidget(self) + dws.append(dockwidget) + + # Add the dock widget to the left dock area + self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dockwidget) + # Add the toggle view action to the menu bar + self.menu_bar.view_menu.addAction(dockwidget.toggleViewAction()) + + # Hide the dock widget if it is marked as hidden + if pane_name not in app.settings["startup"]["shown_panes"]: + dockwidget.hide() + + # Synchronize the pane + pane.sync() + + # Tabify the dock widgets for better organization + for dw in dws: + if dw == dws[0]: + continue + self.tabifyDockWidget(dws[0], dw) + + # Raise the first dock widget to the top + dws[0].raise_() + + def sync_pages(self): + """ + Synchronizes the central widget pages with the shown_pages setting. + + This method shows only those pages that are configured to be shown at startup. + Pages are pre-initialized and just added/removed from tabs. + """ + # Get shown pages from settings + shown_pages = app.settings["startup"].get("shown_pages", []) + + # Remove all pages from tabs first + while self.central_widget.count() > 0: + self.central_widget.removeTab(0) + + # Add only the pages that should be shown + for page_name in shown_pages: + if page_name in self.base_pages: + page_instance = self.base_pages[page_name] + # Base pages should show minimize button instead of close + self.central_widget.addTab(page_instance, page_name, show_minimize=True) + + def show_page(self, page_name: str): + """ + Show a page by adding it to the tabs. + + Args: + page_name: The name of the page to show + """ + if page_name not in self.base_pages: + return + + page_widget = self.base_pages[page_name] + + # Check if page is already in tabs + index = self.central_widget.indexOf(page_widget) + if index >= 0: + # Already shown, just switch to it + self.central_widget.setCurrentIndex(index) + else: + # Add to tabs with minimize button + self.central_widget.addTab(page_widget, page_name, show_minimize=True) + self.central_widget.setCurrentWidget(page_widget) + + def hide_page(self, page_name: str): + """ + Hide a page by removing it from the tabs (but not destroying it). + + Args: + page_name: The name of the page to hide + """ + if page_name not in self.base_pages: + return + + page_widget = self.base_pages[page_name] + index = self.central_widget.indexOf(page_widget) + if index >= 0: + self.central_widget.removeTab(index) + + def toggle_page(self, page_name: str): + """ + Toggle a page shown/hidden. + + Args: + page_name: The name of the page to toggle + """ + if page_name not in self.base_pages: + return + + page_widget = self.base_pages[page_name] + index = self.central_widget.indexOf(page_widget) + + if index >= 0: + # Page is shown, hide it + self.hide_page(page_name) + else: + # Page is hidden, show it + self.show_page(page_name) + + def is_page_visible(self, page_name: str) -> bool: + """ + Check if a page is currently visible in the tabs. + + Args: + page_name: The name of the page to check + + Returns: + bool: True if the page is visible, False otherwise + """ + if page_name not in self.base_pages: + return False + + page_widget = self.base_pages[page_name] + return self.central_widget.indexOf(page_widget) >= 0 + + def _on_tab_close_requested(self, index: int): + """ + Handle when user clicks the close button on a tab. + For base pages, we just hide them instead of destroying them. + + Args: + index: The index of the tab to close + """ + widget = self.central_widget.widget(index) + if widget is None: + return + + # Check if this is a base page + page_name = widget.objectName() + if page_name in self.base_pages: + # Just remove from tabs, don't destroy + self.central_widget.removeTab(index) + else: + # For non-base pages, remove and destroy + self.central_widget.removeTab(index) + widget.deleteLater() + + def apply_settings(self, load=False): + + base_dir = Path(app.settings["startup"]["brightway_directory"]) + + if load or base_dir != bd.projects._base_data_dir: + project_name = app.settings["startup"]["startup_project"] + bd.projects.change_base_directories(base_dir, project_name=project_name, update=False) + + if not bd.projects.twofive: + logger.warning(f"Project: {bd.projects.current} is not yet BW25 compatible") + app.actions.ProjectSwitch.set_warning_bar() + + # Apply color scheme settings + if app.settings["appearance"]["theme"] == "dark": + hint = QtCore.Qt.ColorScheme.Dark + elif app.settings["appearance"]["theme"] == "light": + hint = QtCore.Qt.ColorScheme.Light + else: + hint = QtCore.Qt.ColorScheme.Unknown + + app.application.styleHints().setColorScheme(hint) + + # apply pane tab position + position = app.settings["appearance"]["pane_tab_position"] + if position == "top": + qt_position = QtWidgets.QTabWidget.North + if position == "bottom": + qt_position = QtWidgets.QTabWidget.South + if position == "left": + qt_position = QtWidgets.QTabWidget.West + if position == "right": + qt_position = QtWidgets.QTabWidget.East + self.setTabPosition(QtCore.Qt.DockWidgetArea.AllDockWidgetAreas, qt_position) + + def connect_signals(self): + app.signals.project.changed.connect(self.sync) + app.signals.settings.changed.connect(self.apply_settings) + + def clearPanes(self): + for pane in self.panes(): + logger.debug(f"Clearing pane {pane.__class__.__name__}: {id(pane)}") + pane.deleteLater() + + def panes(self): + """ + Return a list of all panes in the main window. + """ + from activity_browser.ui import widgets + QtWidgets.QApplication.processEvents() + return self.findChildren(widgets.ABAbstractPane) + + def set_titlebar(self): + self.setWindowTitle(f"Activity Browser - {bd.projects.current}") + + def dialog_on_exception(self, exception: Exception): + QtWidgets.QMessageBox.critical( + self, + f"An error occurred: {type(exception).__name__}", + f"An error occurred, check the logs for more information \n\n {str(exception)}", + QtWidgets.QMessageBox.Ok, + ) + diff --git a/activity_browser/app/menu_bar.py b/activity_browser/app/menu_bar.py new file mode 100644 index 000000000..46f25507f --- /dev/null +++ b/activity_browser/app/menu_bar.py @@ -0,0 +1,336 @@ +from importlib.metadata import version +from loguru import logger + +import bw2data as bd + +from qtpy import QtGui, QtWidgets, QtCore +from qtpy.QtCore import QSize, QUrl, Qt + +from activity_browser import app +from activity_browser.bwutils.commontasks import get_templates + +from ..ui.icons import qicons + + +class MenuBar(QtWidgets.QMenuBar): + """ + Main menu bar at the top of the Activity Browser window. Contains submenus for different user interaction categories + """ + def __init__(self, window): + super().__init__(parent=window) + + self.project_menu = ProjectMenu(self) + self.view_menu = ViewMenu(self) + self.calculate_menu = CalculateMenu(self) + self.help_menu = HelpMenu(self) + + self.addMenu(self.project_menu) + self.addMenu(self.view_menu) + self.addMenu(self.calculate_menu) + self.addMenu(self.help_menu) + + self.search_button = QtWidgets.QPushButton(self) + self.search_button.setFlat(True) + self.search_button.setIcon(qicons.search) + self.search_button.setIconSize(QtCore.QSize(13, 13)) + self.search_button.setToolTip("Search project (Ctrl+Shift+F)") + self.search_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.search_button.clicked.connect(app.actions.NodeSelectOpen.run) + self.setCornerWidget(self.search_button, Qt.Corner.TopRightCorner) + + +class ProjectMenu(QtWidgets.QMenu): + """ + Project menu: contains actions related to managing the project, such as project duplication, database importing etc. + """ + + def __init__(self, parent=None) -> None: + super().__init__(parent) + + self.setTitle("&Project") + + self.dup_proj_action = app.actions.ProjectDuplicate.get_QAction() + self.delete_proj_action = app.actions.ProjectDelete.get_QAction() + + self.import_proj_action = app.actions.ProjectImport.get_QAction() + self.export_proj_action = app.actions.ProjectExport.get_QAction() + + self.addMenu(ProjectSelectionMenu(self)) + self.addMenu(ProjectNewMenu(self)) + self.addAction(self.dup_proj_action) + self.addAction(self.delete_proj_action) + self.addSeparator() + self.addAction(self.import_proj_action) + self.addAction(self.export_proj_action) + self.addSeparator() + self.addMenu(ImportDatabaseMenu(self)) + self.addMenu(ExportDatabaseMenu(self)) + self.addSeparator() + self.addMenu(ImportICMenu(self)) + + +class ProjectNewMenu(QtWidgets.QMenu): + def __init__(self, parent=None) -> None: + super().__init__(parent) + + self.setTitle("New project") + self.new_proj_action = app.actions.ProjectNew.get_QAction() + self.import_proj_action = app.actions.ProjectImport.get_QAction() + + self.new_proj_action.setText("Empty project") + self.import_proj_action.setText("From .tar.gz file") + + self.new_proj_action.setIcon(QtGui.QIcon()) + self.import_proj_action.setIcon(QtGui.QIcon()) + + self.addAction(self.new_proj_action) + self.addAction(self.import_proj_action) + self.addMenu(ProjectNewTemplateMenu(self)) + + +class ProjectNewTemplateMenu(QtWidgets.QMenu): + remote_projects = {} + + def __init__(self, parent=None): + super().__init__(parent) + self.setTitle("From template") + + self.actions = {} + + for key in get_templates(): + action = app.actions.ProjectNewFromTemplate.get_QAction(key) + action.setText(key) + self.actions[key] = action + self.addAction(action) + + for key in self.get_projects(): + action = app.actions.ProjectNewRemote.get_QAction(key) + action.setText(key) + self.actions[key] = action + self.addAction(action) + + def get_projects(self): + if not self.remote_projects: + from bw2io.remote import get_projects + ProjectNewTemplateMenu.remote_projects = get_projects() + return self.remote_projects + + +class ViewMenu(QtWidgets.QMenu): + """ + View menu: contains actions in regard to hiding and showing specific UI elements. + """ + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setTitle("&View") + + + # Populate pages + self.page_actions = {} + for page_name in app.pages.base_pages.keys(): + action = QtWidgets.QAction(page_name, self) + action.setCheckable(True) + action.triggered.connect(lambda checked, name=page_name: app.main_window.toggle_page(name)) + # Update checked state when menu is about to show + self.page_actions[page_name] = action + self.addAction(action) + + # Update the checked state when menu is about to show + self.aboutToShow.connect(self.update_page_actions) + + self.addSeparator() + + def update_page_actions(self): + """Update the checked state of page actions based on which pages are visible.""" + for page_name, action in self.page_actions.items(): + is_visible = app.main_window.is_page_visible(page_name) + action.setChecked(is_visible) + + +class CalculateMenu(QtWidgets.QMenu): + """ + Calculate Menu: contains actions in regard to calculating the LCA results for the current project + """ + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setTitle("&Calculate") + self.cs_actions = [] + + self.new_cs_action = app.actions.CSNew.get_QAction() + self.new_cs_action.setText("New setup...") + self.addAction(self.new_cs_action) + self.addSeparator() + + app.signals.project.changed.connect(self.sync) + app.signals.meta.calculation_setups_changed.connect(self.sync) + + def sync(self): + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.cs_actions.clear() + for cs in bd.calculation_setups: + action = app.actions.CSOpen.get_QAction(cs) + action.setText(cs) + self.cs_actions.append(action) + self.addAction(action) + + +class HelpMenu(QtWidgets.QMenu): + """ + Help Menu: contains actions that show info to the user or redirect them to online resources + """ + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setTitle("&Help") + + self.addAction( + qicons.ab, "&About Activity Browser", self.about + ) + self.addAction( + "&About Qt", lambda: QtWidgets.QMessageBox.aboutQt(app.main_window) + ) + self.addAction( + qicons.question, "&Get help on the wiki", self.open_wiki + ) + self.addAction( + qicons.issue, "&Report an idea/issue on GitHub", self.raise_issue_github + ) + + def about(self): + """Displays an 'about' window to the user containing e.g. the version of the AB and copyright info""" + # set the window text in html format + text = f""" + Activity Browser - a graphical interface for Brightway2.

+ Application version: {version("activity_browser")}
+ bw2data version: {version("bw2data")}
+ bw2io version: {version("bw2calc")}
+ bw2calc version: {version("bw2io")}

+ All development happens on github.

+ For copyright information please see the copyright on this page.

+ For license information please see the copyright on this page.

+ """ + + # set up the window + about_window = QtWidgets.QMessageBox(parent=app.main_window) + about_window.setWindowTitle("About the Activity Browser") + about_window.setIconPixmap(qicons.ab.pixmap(QSize(150, 150))) + about_window.setText(text) + + # execute + about_window.exec_() + + def open_wiki(self): + """Opens the AB github wiki in the users default browser""" + url = QUrl( + "https://github.com/LCA-ActivityBrowser/activity-browser/wiki" + ) + QtGui.QDesktopServices.openUrl(url) + + def raise_issue_github(self): + """Opens the github create issue page in the users default browser""" + url = QUrl( + "https://github.com/LCA-ActivityBrowser/activity-browser/issues/new/choose" + ) + QtGui.QDesktopServices.openUrl(url) + + +class ProjectSelectionMenu(QtWidgets.QMenu): + """ + Menu that lists all the projects available through bw2data.projects + """ + def __init__(self, parent=None): + super().__init__(parent) + self.setTitle("Open project") + self.populate() + + self.aboutToShow.connect(self.populate) + self.triggered.connect(lambda act: app.actions.ProjectSwitch.run(act.data())) + + def populate(self): + """ + Populates the menu with the projects available in the database + """ + import bw2data as bd + + # clear the menu of any already existing actions + self.clear() + + # sort projects alphabetically + sorted_projects = sorted(list(bd.projects)) + + # iterate over the sorted projects and add them as actions to the menu + for i, proj in enumerate(sorted_projects): + # check whether the project is BW25 + bw_25 = ( + False if not isinstance(proj.data, dict) else proj.data.get("25", False) + ) + + # create the action and disable it if it's BW25 and BW25 is not supported + action = QtWidgets.QAction(proj.name, self) + action.setData(proj.name) + action.setIcon( + app.application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) if not bw_25 else qicons.empty) + + self.addAction(action) + + +class ImportDatabaseMenu(QtWidgets.QMenu): + def __init__(self, parent=None) -> None: + super().__init__(parent=parent) + self.setTitle("Import database") + self.setIcon(qicons.import_db) + + self.import_from_ecoinvent_action = app.actions.DatabaseImportFromEcoinvent.get_QAction() + self.import_from_excel_action = app.actions.DatabaseImporterExcel.get_QAction() + self.import_from_bw2package_action = app.actions.DatabaseImporterBW2Package.get_QAction() + + self.import_from_ecoinvent_action.setText("ecoinvent...") + self.import_from_excel_action.setText("from .xlsx") + self.import_from_bw2package_action.setText("from .bw2package") + + self.addAction(self.import_from_excel_action) + self.addAction(self.import_from_bw2package_action) + self.addSeparator() + self.addAction(self.import_from_ecoinvent_action) + + +class ExportDatabaseMenu(QtWidgets.QMenu): + def __init__(self, parent=None) -> None: + super().__init__(parent=parent) + self.setTitle("Export database") + + self.export_to_excel_action = app.actions.DatabaseExportExcel.get_QAction() + self.export_to_bw2package_action = app.actions.DatabaseExportBW2Package.get_QAction() + + self.export_to_excel_action.setText("to .xlsx") + self.export_to_bw2package_action.setText("to .bw2package") + + self.addAction(self.export_to_excel_action) + self.addAction(self.export_to_bw2package_action) + + +class ImportICMenu(QtWidgets.QMenu): + def __init__(self, parent=None) -> None: + super().__init__(parent=parent) + self.setTitle("Import impact categories") + self.setIcon(qicons.import_db) + + self.beta_warning = QtWidgets.QWidgetAction(self) + self.beta_warning.setDefaultWidget(QtWidgets.QLabel("Beta features, use at your own risk")) + + self.import_from_ei_excel_action = app.actions.MethodImporterEcoinvent.get_QAction() + self.import_from_bw2io_action = app.actions.MethodImporterBW2IO.get_QAction() + + self.import_from_ei_excel_action.setText("from ecoinvent excel") + self.import_from_bw2io_action.setText("from bw2io") + + self.import_from_ei_excel_action.setIcon(QtGui.QIcon()) + self.import_from_bw2io_action.setIcon(QtGui.QIcon()) + + self.addAction(self.beta_warning) + self.addSeparator() + self.addAction(self.import_from_ei_excel_action) + self.addAction(self.import_from_bw2io_action) diff --git a/activity_browser/app/pages/README.md b/activity_browser/app/pages/README.md new file mode 100644 index 000000000..fda37b35c --- /dev/null +++ b/activity_browser/app/pages/README.md @@ -0,0 +1,88 @@ +# pages + +Main content pages displayed in the Activity Browser application. + +## Overview + +This directory contains the primary content pages that users interact with in Activity Browser. Each page represents a major functional area and is displayed in the central widget of the main window. + +## Directory Structure + +- **`activity_details/`** - Activity information display and editing +- **`calculation_setup/`** - Calculation setup configuration and management +- **`impact_category_details/`** - Impact category information and visualization +- **`lca_results/`** - LCA calculation results display and analysis +- **`parameters/`** - Parameter management and scenario configuration +- **`settings/`** - Application settings and preferences + +## Key Files + +- **`welcome.py`** - Welcome page shown when no project is open or on first launch +- **`metadatastore.py`** - Metadata view page (DEBUG only) + +## Two types of pages + +1. **Base pages** - Pages that are initialized once and remain in memory (e.g., Welcome Screen, Parameters, Settings). + - They maintain their state and reload data on project switches. + - Hidden/shown based on user actions or preferences in the settings. + - Defined in `__init__.py`. +2. **Dynamic pages** - Pages that show specific data and are opened as such by the user (e.g. Activity Details, LCA results). + - Created on demand and closed when no longer needed. + - Multiple instances can exist (e.g., multiple activity detail pages) and will be grouped. + +## Development Guidelines + +When creating new pages: + +- Should follow the `PageNamePage` naming convention. +- Set a unique ObjectName for identification. +- Set appropriate tab titles using `setWindowTitle()`. + +## Subdirectory Details + +### `activity_details/` +Display and edit activity information including: +- Basic activity data (name, location, unit, etc.) +- Exchanges (inputs/outputs) +- Parameters and formulas +- Metadata and classifications + +### `calculation_setup/` +Configure and manage calculation setups: +- Reference flows (functional units) +- Impact assessment methods +- Scenario selections +- Calculation execution + +### `impact_category_details/` +Show impact category information: +- Characterization factors +- Method hierarchy +- Method metadata + +### `lca_results/` +Display LCA calculation results: +- Impact scores +- Contribution analyses +- Sankey diagrams +- Graph visualizations +- Export options + +### `parameters/` +[BASE PAGE] + +Manage parameters and scenarios: +- Project parameters +- Database parameters +- Activity parameters +- Parameter formulas +- Scenario management + +### `settings/` +[BASE PAGE] + +Application configuration: +- General preferences +- Project settings +- Plugin configuration +- Import/export settings diff --git a/activity_browser/app/pages/__init__.py b/activity_browser/app/pages/__init__.py new file mode 100644 index 000000000..1f360e945 --- /dev/null +++ b/activity_browser/app/pages/__init__.py @@ -0,0 +1,14 @@ +from .activity_details import ActivityDetailsPage +from .welcome import WelcomePage +from .calculation_setup import CalculationSetupPage +from .impact_category_details import ImpactCategoryDetailsPage +from .lca_results import LCAResultsPage +from .parameters import ParametersPage +from .metadatastore import MetaDataStorePage +from .settings import SettingsPage + +base_pages = { + "Welcome": WelcomePage, + "Parameters": ParametersPage, + "Settings": SettingsPage, +} diff --git a/activity_browser/app/pages/activity_details/__init__.py b/activity_browser/app/pages/activity_details/__init__.py new file mode 100644 index 000000000..76985c63a --- /dev/null +++ b/activity_browser/app/pages/activity_details/__init__.py @@ -0,0 +1 @@ +from .activity_details import ActivityDetailsPage \ No newline at end of file diff --git a/activity_browser/app/pages/activity_details/activity_details.py b/activity_browser/app/pages/activity_details/activity_details.py new file mode 100644 index 000000000..5b2ae11e6 --- /dev/null +++ b/activity_browser/app/pages/activity_details/activity_details.py @@ -0,0 +1,169 @@ +from loguru import logger + +from qtpy import QtCore, QtWidgets + +import bw2data as bd + +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node_or_none +from activity_browser.ui import widgets + +from .activity_header import ActivityHeader +from .exchanges_tab import ExchangesTab +from .description_tab import DescriptionTab +from .graph_tab import GraphTab +from .parameters_tab import ParametersTab +from .data_tab import DataTab +from .consumers_tab import ConsumersTab + + +class ActivityDetailsPage(QtWidgets.QWidget): + """ + A widget that displays detailed information about a specific activity. + + Attributes: + activity (tuple | int | bd.Node): The activity to display details for. + activity_data_grid (ActivityHeader): The header widget displaying activity data. + tabs (QtWidgets.QTabWidget): The tab widget containing various detail tabs. + exchanges_tab (ExchangesTab): The tab displaying exchanges related to the activity. + description_tab (DescriptionTab): The tab displaying the description of the activity. + graph_explorer (GraphTab): The tab displaying the graph related to the activity. + parameters_tab (ParametersTab): The tab displaying parameters of the activity. + consumer_tab (ConsumersTab): The tab displaying consumers of the activity. + data_tab (DataTab): The tab displaying data related to the activity. + """ + _populate_later_flag = False + + def __init__(self, activity: tuple | int | bd.Node, parent=None): + """ + Initializes the ActivityDetailsPage widget. + + Args: + activity (tuple | int | bd.Node): The activity to display details for. + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + self.activity = bd.get_activity(activity) + self.setObjectName(f"activity_details_{self.activity['database']}_{self.activity['code']}") + self.setWindowTitle(self.activity["name"]) + + # Initialize header widget for activity data + self.activity_data_grid = ActivityHeader(self) + # Initialize tab widget to hold various detail tabs + self.tabs = QtWidgets.QTabWidget(self) + + # Initialize and add the Exchanges tab + self.exchanges_tab = ExchangesTab(activity, self) + self.tabs.addTab(self.exchanges_tab, "Exchanges") + + # Initialize and add the Description tab + self.description_tab = DescriptionTab(activity, self) + self.tabs.addTab(self.description_tab, "Description") + + # Initialize and add the Graph tab + self.graph_explorer = GraphTab(activity, self) + self.tabs.addTab(self.graph_explorer, "Graph") + + # Initialize and add the Parameters tab + self.parameters_tab = ParametersTab(activity, self) + self.tabs.addTab(self.parameters_tab, "Parameters") + + # Initialize and add the Consumers tab + self.consumer_tab = ConsumersTab(activity, self) + self.tabs.addTab(self.consumer_tab, "Consumers") + + # Initialize and add the Data tab + self.data_tab = DataTab(activity, self) + self.tabs.addTab(self.data_tab, "Data") + + # Build the layout of the widget + self.build_layout() + # Synchronize the widget with the current state of the activity + self.sync() + # Connect signals to their respective slots + self.connect_signals() + + def build_layout(self): + """ + Builds the layout of the widget. + """ + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(10, 10, 4, 1) + layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + + # Add the activity data grid and tabs to the layout + layout.addWidget(self.activity_data_grid) + layout.addWidget(widgets.ABHLine(self)) + layout.addWidget(self.tabs) + + self.setLayout(layout) + + def connect_signals(self): + """ + Connects signals to their respective slots. + """ + app.signals.node.deleted.connect(self.on_node_deleted) + app.signals.database.deleted.connect(self.on_database_deleted) + app.signals.meta.databases_changed.connect(self.syncLater) + app.signals.parameter.recalculated.connect(self.syncLater) + app.signals.node.changed.connect(self.syncLater) + + def on_node_deleted(self, node): + """ + Slot to handle node deletion. + + Args: + node: The node that was deleted. + """ + if node.id == self.activity.id: + self.deleteLater() + + def on_database_deleted(self, name): + """ + Slot to handle database deletion. + + Args: + name: The name of the database that was deleted. + """ + if name == self.activity["database"]: + self.deleteLater() + + def syncLater(self): + """ + Schedules a sync operation to be performed later. + """ + + def slot(): + self._populate_later_flag = False + self.sync() + self.thread().eventDispatcher().awake.disconnect(slot) + + if self._populate_later_flag: + return + + self._populate_later_flag = True + self.thread().eventDispatcher().awake.connect(slot) + + def sync(self): + """ + Synchronizes the widget with the current state of the activity. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.activity = refresh_node_or_none(self.activity) + + if self.activity is None: + # Activity was already deleted + return + + # Update the tab name to be the activity name + self.setWindowTitle(self.activity["name"]) + + # Synchronize all tabs with the current state of the activity + self.activity_data_grid.sync() + self.exchanges_tab.sync() + self.description_tab.sync() + self.consumer_tab.sync() + self.data_tab.sync() + self.parameters_tab.sync() + self.graph_explorer.sync() diff --git a/activity_browser/app/pages/activity_details/activity_header.py b/activity_browser/app/pages/activity_details/activity_header.py new file mode 100644 index 000000000..c9828ef82 --- /dev/null +++ b/activity_browser/app/pages/activity_details/activity_header.py @@ -0,0 +1,310 @@ +from qtpy import QtWidgets, QtCore +from loguru import logger + +import bw2data as bd +import bw_functional as bf + +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node, database_is_locked +from activity_browser.ui import widgets + + +class ActivityHeader(QtWidgets.QWidget): + """ + A widget that displays the header information of a specific activity. + + Attributes: + DATABASE_DEFINED_ALLOCATION (str): Constant for database default allocation. + CUSTOM_ALLOCATION (str): Constant for custom allocation. + activity (bd.Node): The activity to display the header for. + """ + DATABASE_DEFINED_ALLOCATION = "(database default)" + CUSTOM_ALLOCATION = "Custom..." + + def __init__(self, parent: QtWidgets.QWidget): + """ + Initializes the ActivityHeader widget. + + Args: + parent (QtWidgets.QWidget): The parent widget. + """ + super().__init__(parent) + self.activity = parent.activity + + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + def sync(self): + """ + Synchronizes the widget with the current state of the activity. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.activity = refresh_node(self.activity) + + self.clear_layout() + + if database_is_locked(self.activity["database"]): + self.layout().addWidget(LockedWarningBar(self)) + + self.layout().addLayout(self.build_grid()) + + def clear_layout(self, layout: QtWidgets.QLayout = None): + layout = layout or self.layout() + + if layout is None: + return + + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + elif item.layout() is not None: + self.clear_layout(item.layout()) + + def build_grid(self) -> QtWidgets.QGridLayout: + grid = QtWidgets.QGridLayout(self) + grid.setContentsMargins(0, 5, 0, 5) + grid.setSpacing(10) + grid.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + + db_locked = database_is_locked(self.activity["database"]) + setup = self.disabled_setup() if db_locked else self.enabled_setup() + + # Arrange widgets for display as a grid + for i, (title, widget) in enumerate(setup): + grid.addWidget(widgets.ABLabel.demiBold(title, self), i, 1) + grid.addWidget(widget, i, 2, 1, 4) + + return grid + + def enabled_setup(self): + setup = [ + ("Name:", ActivityName(self),), + ("Location:", ActivityLocation(self),), + ] + + if isinstance(self.activity, bf.Process): + setup.append(("Properties:", ActivityProperties(self),),) + + # Add allocation strategy selector if the activity is multifunctional + if self.activity.get("type") == "multifunctional": + setup.append(("Allocation:", ActivityAllocation(self),)) + + return setup + + def disabled_setup(self): + setup = [ + ("Name:", QtWidgets.QLabel(self.activity.get("name", "unspecified"), self),), + ("Location:", QtWidgets.QLabel(self.activity.get("location", "unspecified"), self),), + ] + + if isinstance(self.activity, bf.Process): + props = self.activity.available_properties() + prop_text = ", ".join(props) if props else "None" + setup.append(("Properties:", QtWidgets.QLabel(prop_text, self),)) + + # Add allocation strategy selector if the activity is multifunctional + if self.activity.get("type") == "multifunctional": + setup.append(("Allocation:", QtWidgets.QLabel(self.activity.get("allocation", "unspecified"), self),),) + + return setup + + + + + +class ActivityName(QtWidgets.QLineEdit): + """ + A widget that displays and edits the name of the activity. + """ + + def __init__(self, parent: ActivityHeader): + """ + Initializes the ActivityName widget. + + Args: + parent (ActivityHeader): The parent widget. + """ + super().__init__(parent.activity["name"], parent) + self.editingFinished.connect(self.change_name) + + def change_name(self): + """ + Changes the name of the activity if it has been modified. + """ + if self.text() == self.parent().activity["name"]: + return + app.actions.ActivityModify.run(self.parent().activity, "name", self.text()) + + +class ActivityLocation(QtWidgets.QLineEdit): + """ + A widget that displays and edits the location of the activity. + """ + + def __init__(self, parent: ActivityHeader): + """ + Initializes the ActivityLocation widget. + + Args: + parent (ActivityHeader): The parent widget. + """ + super().__init__(parent.activity.get("location"), parent) + self.editingFinished.connect(self.change_location) + + locations = set(app.metadata.dataframe.get("location", ["GLO"])) + completer = QtWidgets.QCompleter(locations, self) + self.setCompleter(completer) + + def change_location(self): + """ + Changes the location of the activity if it has been modified. + """ + if self.text() == self.parent().activity.get("location"): + return + app.actions.ActivityModify.run(self.parent().activity, "location", self.text()) + + +class ActivityProperties(QtWidgets.QWidget): + """ + A widget that displays and edits the properties of the activity. + """ + + def __init__(self, parent: ActivityHeader): + """ + Initializes the ActivityProperties widget. + + Args: + parent (ActivityHeader): The parent widget. + """ + super().__init__(parent) + + self.setContentsMargins(0, 0, 0, 0) + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + if not isinstance(parent.activity, bf.Process): + return + + for property_name in parent.activity.available_properties(): + layout.addWidget(ActivityProperty(parent.activity, property_name)) + + add_label = QtWidgets.QLabel("Add property") + add_label.mouseReleaseEvent = lambda x: app.actions.ProcessPropertyModify.run(parent.activity) + + layout.addWidget(add_label) + + layout.addStretch(1) + + +class ActivityProperty(QtWidgets.QPushButton): + """ + A widget that represents a single property of the activity. + """ + + def __init__(self, activity, property_name): + """ + Initializes the ActivityProperty widget. + + Args: + activity (bd.Node): The activity to which the property belongs. + property_name (str): The name of the property. + """ + super().__init__(property_name, None) + + self.modify_action = app.actions.ProcessPropertyModify.get_QAction(activity, property_name) + self.remove_action = app.actions.ProcessPropertyRemove.get_QAction(activity, property_name) + + self.menu = QtWidgets.QMenu(self) + self.menu.addAction(self.modify_action) + self.menu.addAction(self.remove_action) + + self.setStyleSheet(""" + QPushButton { + border: 1px solid #8f8f91; + border-radius: 0px; + padding: 1px 10px 1px 10px; + min-width: 0px; + } + """) + + def mouseReleaseEvent(self, e): + """ + Handles the mouse release event to show the context menu. + + Args: + e: The mouse release event. + """ + pos = self.geometry().bottomLeft() + pos = self.parent().mapToGlobal(pos) + self.menu.exec_(pos) + e.accept() + + +class ActivityAllocation(QtWidgets.QComboBox): + """ + A widget that displays and edits the allocation strategy of the activity. + """ + + def __init__(self, parent: ActivityHeader): + """ + Initializes the ActivityAllocation widget. + + Args: + parent (ActivityHeader): The parent widget. + """ + if not isinstance(parent.activity, bf.Process): + raise TypeError("ActivityAllocation can only be used with bf.Process instances.") + + super().__init__(parent) + + self.addItems(sorted(bf.allocation_strategies)) + if props := parent.activity.available_properties(): + self.insertSeparator(1000) # Large number to make sure it's appended at the end + self.addItems(sorted(props)) + + i = self.findText(parent.activity.get("allocation")) + self.setCurrentIndex(i) + + self.currentTextChanged.connect(self.change_allocation) + + def change_allocation(self, allocation: str): + """ + Changes the allocation strategy of the activity if it has been modified. + + Args: + allocation (str): The new allocation strategy. + """ + act = self.parent().activity + if act.get("allocation") == allocation: + return + app.actions.ActivityModify.run(act, "allocation", allocation) + + +class LockedWarningBar(QtWidgets.QToolBar): + def __init__(self, parent: ActivityHeader): + super().__init__(parent) + self.setMovable(False) + self.setContentsMargins(0, 0, 0, 0) + + warning_label = QtWidgets.QLabel("The database of this activity is currently locked.") + height = warning_label.minimumSizeHint().height() + + warning_icon = QtWidgets.QLabel(self) + qicon = app.application.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) + pixmap = qicon.pixmap(height, height) + warning_icon.setPixmap(pixmap) + + migrate_label = QtWidgets.QLabel("Unlock database") + migrate_label.mouseReleaseEvent = lambda x: app.actions.DatabaseSetReadonly.run(parent.activity["database"], False) + + self.addWidget(warning_icon) + self.addWidget(warning_label) + self.addWidget(migrate_label) + + def contextMenuEvent(self, event): + return None + diff --git a/activity_browser/app/pages/activity_details/consumers_tab.py b/activity_browser/app/pages/activity_details/consumers_tab.py new file mode 100644 index 000000000..02546bded --- /dev/null +++ b/activity_browser/app/pages/activity_details/consumers_tab.py @@ -0,0 +1,163 @@ +from qtpy import QtWidgets +from loguru import logger + +import pandas as pd +import bw2data as bd +import bw_functional as bf + +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node +from activity_browser.ui import widgets, icons, core + + +class ConsumersTab(QtWidgets.QWidget): + """ + A widget that displays consumers related to a specific activity. + + Attributes: + activity (tuple | int | bd.Node): The activity to display consumers for. + view (ConsumersView): The view displaying the consumers. + model (ConsumersModel): The model containing the data for the consumers. + """ + def __init__(self, activity: tuple | int | bd.Node, parent=None): + """ + Initializes the ConsumersTab widget. + + Args: + activity (tuple | int | bd.Node): The activity to display consumers for. + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + + self.activity = refresh_node(activity) + + self.view = ConsumersView(self) + self.model = ConsumersModel(parent=self, enable_sorting=True) + self.view.setModel(self.model) + self.view.setSortingEnabled(True) + + self.build_layout() + self.sync() + + def build_layout(self): + """ + Builds the layout of the widget. + """ + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 10, 0, 1) + layout.addWidget(self.view) + self.setLayout(layout) + + def sync(self): + """ + Synchronizes the widget with the current state of the activity. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.activity = refresh_node(self.activity) + exchanges = [] + if isinstance(self.activity, bf.Process): + for product in self.activity.products(): + exchanges += list(product.upstream()) + else: + exchanges = list(self.activity.upstream()) + + df = self.build_df(exchanges) + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) + + def build_df(self, exchanges: list[bd.Edge]) -> pd.DataFrame: + """ + Builds a DataFrame from the given exchanges. + + Args: + exchanges (list): The list of exchanges to build the DataFrame from. + + Returns: + pd.DataFrame: The DataFrame containing the exchanges data. + """ + exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "output"]) + input_df = app.metadata.get_metadata(exc_df["input"].unique(), ["name", "type", "unit", "key"]) + output_df = app.metadata.get_metadata(exc_df["output"].unique(), ["name", "type", "key"]) + + df = exc_df.merge( + input_df.rename({"name": "product", "type": "_product_type"}, axis="columns"), + left_on="input", + right_on="key", + ).drop(columns=["key"]) + + df = df.merge( + output_df.rename({"name": "consumer", "type": "_consumer_type"}, axis="columns"), + left_on="output", + right_on="key", + ).drop(columns=["key"]) + + df = df.rename({"input": "_product_key", "output": "_consumer_key"}, axis="columns") + + cols = ["amount", "unit", "product", "consumer"] + cols += [col for col in df.columns if col.startswith("_")] + + return df[cols] + + +class ConsumersView(widgets.ABTreeView): + """ + A view that displays the consumers in a tree structure. + """ + def mouseDoubleClickEvent(self, event) -> None: + """ + Handles the mouse double-click event. + + Args: + event: The mouse event. + """ + indexes = self.selectedIndexes() + if not indexes: + return super().mouseDoubleClickEvent(event) + + keys = self.model().values_from_indices("_consumer_key", indexes) + if keys: + app.actions.ActivityOpen.run(keys) + + +class ConsumersModel(core.ABTreeModel): + """ + A model representing the data for the consumers. + """ + + def decorationData(self, index): + """ + Provides decoration data for the model. + + Args: + index: The index for which to provide decoration data. + + Returns: + The decoration data for the model. + """ + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return None + + if column_name not in ["product", "consumer"]: + return None + + if column_name == "product": + activity_type = row.get("_product_type") + else: # column_name == "consumer" + activity_type = row.get("_consumer_type") + + if activity_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: + return icons.qicons.biosphere + if activity_type == "processwithreferenceproduct": + return icons.qicons.processproduct + if activity_type == "product": + return icons.qicons.product + if activity_type in ["process", "multifunctional", "nonfunctional"]: + return icons.qicons.process + if activity_type == "waste": + return icons.qicons.waste + + return None diff --git a/activity_browser/app/pages/activity_details/data_tab.py b/activity_browser/app/pages/activity_details/data_tab.py new file mode 100644 index 000000000..b9d2da129 --- /dev/null +++ b/activity_browser/app/pages/activity_details/data_tab.py @@ -0,0 +1,189 @@ +from qtpy import QtWidgets, QtCore +from loguru import logger + +import pandas as pd +import bw2data as bd +import bw_functional as bf + +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node, database_is_locked +from activity_browser.ui import widgets, delegates, core + + +class DataTab(QtWidgets.QWidget): + """ + A widget that displays the data structure of a specific activity. + + Attributes: + activity (tuple | int | bd.Node): The activity to display data for. + data_view (DataView): The view displaying the data. + data_model (DataModel): The model containing the data. + """ + def __init__(self, activity: tuple | int | bd.Node, parent=None): + """ + Initializes the DataTab widget. + + Args: + activity (tuple | int | bd.Node): The activity to display data for. + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + + self.activity = refresh_node(activity) + + # Data TreeView + self.data_view = DataView(self) + self.data_model = DataModel(parent=self) + self.data_view.setModel(self.data_model) + + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.data_model.set_dataframe(df) + self.data_model.group(["_name"]) + self.data_view.expandAll() + + self.build_layout() + + def build_layout(self): + """ + Builds the layout of the widget. + """ + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.data_view) + self.setLayout(layout) + + def sync(self) -> None: + """ + Synchronizes the widget with the current state of the activity. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.activity = refresh_node(self.activity) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.data_model.set_dataframe(df) + self.data_model.group(["_name"]) + self.data_view.expandAll() + + def build_df(self) -> pd.DataFrame: + """ + Builds a DataFrame from the activity data. + + Returns: + pd.DataFrame: The DataFrame containing the activity data. + """ + df = pd.Series(self.activity.as_dict()).to_frame() + df["_name"] = f"{self.activity['name']} {df.get('product', '')} ({self.activity['id']})" + df["_activity_id"] = self.activity.id + df["_activity_db"] = self.activity["database"] + + if isinstance(self.activity, bf.Process): + for product in self.activity.products(): + fn_df = pd.DataFrame.from_dict(product.as_dict(), orient="index") + fn_df["_name"] = f"{product['name']}: {product.get('product', '')} ({product['id']})" + fn_df["_activity_id"] = product.id + fn_df["_activity_db"] = product["database"] + df = pd.concat([df, fn_df]) + + df = df.reset_index() + df = df.rename({"index": "field", 0: "value"}, axis=1) + df = df.sort_values(["_name", "field"], ignore_index=True) + + cols = ["field", "value", "_name", "_activity_id", "_activity_db"] + return df[cols] + + +class DataView(widgets.ABTreeView): + """ + A view that displays the data in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + """ + defaultColumnDelegates = { + "field": delegates.StringDelegate, + "value": delegates.NewFormulaDelegate, + } + + +class DataModel(core.ABTreeModel): + """ + A model representing the data for the activity. + """ + + def setData(self, index: QtCore.QModelIndex, value, role: int = QtCore.Qt.ItemDataRole.EditRole) -> bool: + """ + Sets the data for the given index. + + Args: + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. + + Returns: + bool: True if the data was set successfully, False otherwise. + """ + if role != QtCore.Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False + + if column_name == "value": + value = eval(value) + app.actions.ActivityModify.run(row.get("_activity_id"), row.get("field"), value) + return True + + return False + + def indexEditable(self, index: QtCore.QModelIndex) -> bool: + """ + Returns whether the index is editable. + + Args: + index (QtCore.QModelIndex): The index to check. + + Returns: + bool: True if the index is editable, False otherwise. + """ + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False + + if column_name == "value" and not database_is_locked(row.get("_activity_db")): + return True + + return False + + def displayData(self, index: QtCore.QModelIndex) -> any: + """ + Provides display data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide display data. + + Returns: + The display data for the index. + """ + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + # Branch node + node = index.internalPointer() + if isinstance(node, core.TreeNode): + return node.path[-1] if index.column() == 0 else None + return None + + if column_name == "value": + data = row.get(column_name) + if isinstance(data, str): + return f"'{data}'" + return str(data) + + return row.get(column_name) diff --git a/activity_browser/app/pages/activity_details/description_tab.py b/activity_browser/app/pages/activity_details/description_tab.py new file mode 100644 index 000000000..c5605c7bb --- /dev/null +++ b/activity_browser/app/pages/activity_details/description_tab.py @@ -0,0 +1,51 @@ +from qtpy import QtWidgets, QtGui +from loguru import logger + +import bw2data as bd + +from activity_browser import app +from activity_browser.bwutils.commontasks import refresh_node, database_is_locked + + +class DescriptionTab(QtWidgets.QTextEdit): + """ + A widget that displays and edits the description (comment) of a specific activity. + + Attributes: + activity (tuple | int | bd.Node): The activity to display and edit the description for. + """ + def __init__(self, activity: tuple | int | bd.Node, parent=None): + """ + Initializes the DescriptionTab widget. + + Args: + activity (tuple | int | bd.Node): The activity to display and edit the description for. + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + self.activity = refresh_node(activity) + super().__init__(parent, self.activity.get("comment", "")) + self.setPlaceholderText("Click here to edit the description of this activity...") + + def sync(self): + """ + Synchronizes the widget with the current state of the activity. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.activity = refresh_node(self.activity) + self.setText(self.activity.get("comment", "")) + self.moveCursor(QtGui.QTextCursor.MoveOperation.End) + + # Set the read-only state based on the activity's database + self.setReadOnly(database_is_locked(self.activity["database"])) + + def focusOutEvent(self, e): + """ + Handles the focus out event to save the comment if it has changed. + + Args: + e: The focus out event. + """ + if self.toPlainText() == self.activity.get("comment", ""): + return + app.actions.ActivityModify.run(self.activity, "comment", self.toPlainText()) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py new file mode 100644 index 000000000..91f67620e --- /dev/null +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -0,0 +1,834 @@ +from PySide6.QtCore import QModelIndex +from loguru import logger +from typing import Literal + +from qtpy import QtWidgets, QtGui, QtCore +from qtpy.QtCore import Qt + +import pandas as pd +import bw2data as bd + +import bw_functional as bf + +from activity_browser import app +from activity_browser.bwutils.commontasks import (refresh_node, database_is_locked, database_is_legacy, + is_node_product_or_waste, is_node_biosphere, parameters_in_scope, + is_node_product, is_node_waste) +from activity_browser.ui import widgets, icons, delegates, core + + + +EXCHANGE_MAP = { + "natural resource": "biosphere", "emission": "biosphere", "inventory indicator": "biosphere", + "economic": "biosphere", "social": "biosphere", "product": "technosphere", + "processwithreferenceproduct": "technosphere", "waste": "technosphere", +} + + +class ExchangesTab(QtWidgets.QWidget): + """ + A widget that displays exchanges related to a specific activity. + + Attributes: + activity (tuple | int | bd.Node): The activity to display exchanges for. + output_view (ExchangesView): The view displaying the output exchanges. + output_model (ExchangesModel): The model containing the data for the output exchanges. + input_view (ExchangesView): The view displaying the input exchanges. + input_model (ExchangesModel): The model containing the data for the input exchanges. + """ + def __init__(self, activity: tuple | int | bd.Node, parent=None): + """ + Initializes the ExchangesTab widget. + + Args: + activity (tuple | int | bd.Node): The activity to display exchanges for. + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + self.setAcceptDrops(True) + + # Refresh the activity node + self.activity = refresh_node(activity) + + # Output Table + self.output_view = ExchangesView(self) + self.output_model = ExchangesModel(tab=self) + self.output_view.setModel(self.output_model) + + # Set indentation for output view + self.output_view.setIndentation(0) + + # Input Table + self.input_view = ExchangesView(self) + self.input_model = ExchangesModel(tab=self) + self.input_view.setModel(self.input_model) + + # Set indentation for input view + self.input_view.setIndentation(0) + + # Overlay for drag and drop + self.overlay = None + + # Build the layout of the widget + self.build_layout() + + def build_layout(self): + """ + Builds the layout of the widget. + """ + # Add output label and view to the layout + output = QtWidgets.QWidget(self) + output_layout = QtWidgets.QVBoxLayout(output) + output_layout.addWidget(widgets.ABLabel.demiBold(" Output:", self)) + output_layout.addWidget(self.output_view) + + # Add input label and view to the layout + input = QtWidgets.QWidget(self) + input_layout = QtWidgets.QVBoxLayout(input) + input_layout.addWidget(widgets.ABLabel.demiBold(" Input:", self)) + input_layout.addWidget(self.input_view) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 10, 0, 1) + splitter = QtWidgets.QSplitter(Qt.Orientation.Vertical, self, childrenCollapsible=False) + splitter.addWidget(output) + splitter.addWidget(input) + layout.addWidget(splitter) + + def sync(self) -> None: + """ + Synchronizes the widget with the current state of the activity. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + # Refresh the activity node + self.activity = refresh_node(self.activity) + + # Get the production, technosphere, and biosphere exchanges + production = self.activity.production() + technosphere = self.activity.technosphere() + biosphere = self.activity.biosphere() + substitution = self.activity.substitution() + + # Filter inputs and outputs based on the amount and type + inputs = ([x for x in production if x["amount"] < 0] + + [x for x in technosphere if x["amount"] >= 0] + + [x for x in biosphere if (x.input["type"] != "emission" and x["amount"] >= 0) or (x.input["type"] == "emission" and x["amount"] < 0)] + + [x for x in substitution if x["amount"] < 0] + ) + + outputs = ([x for x in production if x["amount"] >= 0] + + [x for x in technosphere if x["amount"] < 0] + + [x for x in biosphere if (x.input["type"] == "emission" and x["amount"] >= 0) or (x.input["type"] != "emission" and x["amount"] < 0)] + + [x for x in substitution if x["amount"] >= 0] + ) + + # Update the models with the new data + output_df = self.build_df(outputs) + output_df.reset_index(drop=True, inplace=True) + self.output_model.set_dataframe(output_df) + self.output_view.drag_drop_hint.setVisible(output_df.empty) + + input_df = self.build_df(inputs) + input_df.reset_index(drop=True, inplace=True) + self.input_model.set_dataframe(input_df) + self.input_view.drag_drop_hint.setVisible(input_df.empty) + + def build_df(self, exchanges) -> pd.DataFrame: + """ + Builds a DataFrame from the given exchanges. + + Args: + exchanges (list): The list of exchanges to build the DataFrame from. + + Returns: + pd.DataFrame: The DataFrame containing the exchanges data. + """ + # Define the columns for the metadata + cols = ["key", "unit", "name", "product", "location", "database", "allocation_factor", + "properties", "processor", "categories", "type"] + + # Create a DataFrame from the exchanges + exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "formula", "comment", "type"]) + exc_df["uncertainty"] = [x.uncertainty for x in exchanges] + act_df = app.metadata.get_metadata(exc_df["input"].unique(), cols).rename(columns={"type": "_producer_type"}) + + # Merge the exchanges DataFrame with the metadata DataFrame + df = exc_df.merge( + act_df, + left_on="input", + right_on="key" + ).drop(columns=["key"]) + + # Set allocation_factor to NA for non-production exchanges + df.loc[df["type"] != "production", "allocation_factor"] = pd.NA + + # Handle properties data if available + if not act_df.properties.isna().all(): + props_df = act_df[act_df.properties.notna()] + props_df = pd.DataFrame(list(props_df.get("properties")), index=props_df.key) + props_df.rename(lambda col: f"property_{col}", axis="columns", inplace=True) + + df = df.merge( + props_df, + left_on="input", + right_index=True, + how="left", + ) + + # Add allocation and activity type information + df["_allocate_by"] = self.activity.get("allocation") + df["_activity_type"] = self.activity.get("type") + df["_exchange"] = exchanges + + # Drop the properties column and rename some columns + df.drop(columns=["properties"], inplace=True) + df.rename({ + "input": "_input_key", + "processor": "_processor_key", + "type": "_exchange_type", + "name": "producer", + }, axis="columns", inplace=True) + + # Define the order of columns for the final DataFrame + cols = ["amount", "unit", "product", "producer", "location", "categories", "database"] + cols += ["allocation_factor"] if not database_is_legacy(self.activity.get("database")) else [] + cols += [col for col in df.columns if col.startswith("property")] + cols += ["formula", "comment", "uncertainty"] + cols += [col for col in df.columns if col.startswith("_")] + + return df[cols] + + def dragEnterEvent(self, event): + """ + Handles the drag enter event. + + Args: + event: The drag enter event. + """ + if database_is_locked(self.activity["database"]): + return + + has_nodes = event.mimeData().hasFormat("application/bw-nodekeylist") + has_exchanges = event.mimeData().hasFormat("application/bw-exchangelist") + + if not has_nodes and not has_exchanges: + return + + event.accept() + action = self.action_from_mime(event.mimeData()) + + self.input_view.overlay.show() + self.output_view.overlay.show() + + if action == "product": + self.output_view.overlay.setText("Drop to substitute production") + self.input_view.overlay.setText("Drop to consume product") + return + + if action == "waste": + self.output_view.overlay.setText("Drop to produce waste") + self.input_view.overlay.setText("Drop to substitute waste consumption") + return + + if action == "resource": + self.output_view.overlay.hide() + self.input_view.overlay.setText("Drop to consume natural resource") + return + + if action == "emission": + self.input_view.overlay.hide() + self.output_view.overlay.setText("Drop to emit to environment") + return + + + def dragMoveEvent(self, event): + """ + Handles the drag move event to adjust overlay opacity based on hover position. + + Args: + event: The drag move event. + """ + has_nodes = event.mimeData().hasFormat("application/bw-nodekeylist") + has_exchanges = event.mimeData().hasFormat("application/bw-exchangelist") + + if not has_nodes and not has_exchanges: + return + + if self.input_view.overlay.hovering(): + self.input_view.overlay.setOpacity("high") + self.output_view.overlay.setOpacity("medium") + elif self.output_view.overlay.hovering(): + self.output_view.overlay.setOpacity("high") + self.input_view.overlay.setOpacity("medium") + else: + self.input_view.overlay.setOpacity("medium") + self.output_view.overlay.setOpacity("medium") + event.ignore() + return + + event.accept() + + def dragLeaveEvent(self, event): + """ + Handles the drag leave event. + + Args: + event: The drag leave event. + """ + # Reset the palette on drag leave + self.input_view.overlay.hide() + self.output_view.overlay.hide() + + def dropEvent(self, event): + """ + Handles the drop event. + + Args: + event: The drop event. + """ + logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") + + self.input_view.overlay.hide() + self.output_view.overlay.hide() + + output = self.output_view.overlay.hovering() + keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") + + positive_exchanges = {"technosphere": set(), "biosphere": set(), "substitution": set()} + negative_exchanges = {"technosphere": set(), "substitution": set()} + + for key in keys: + exc_type = get_exchange_type(key, output=output) + if exc_type is None: + continue + if exc_type.startswith("-"): + negative_exchanges[exc_type[1:]].add(key) + else: + positive_exchanges[exc_type].add(key) + + # Run the action for new exchanges + for exc_type, keys in positive_exchanges.items(): + app.actions.ExchangeNew.run(keys, self.activity.key, exc_type) + for exc_type, keys in negative_exchanges.items(): + app.actions.ExchangeNew.run(keys, self.activity.key, exc_type, amount=-1) + + def action_from_mime(self, mime: core.ABMimeData) -> Literal["product", "waste", "resource", "emission", "generic"]: + """ + Determines the appropriate action based on the mime data. + + Args: + mime (core.ABMimeData): The mime data. + + """ + keys = mime.retrievePickleData("application/bw-nodekeylist") + data = app.metadata.get_metadata(keys, ["type"]) + data = set(data["type"].unique()) + data.discard("process") + data.discard("multifunctional") + data.discard("nonfunctional") + + if len(data) != 1: + return "generic" + + node_type = data.pop() + if node_type in ["product", "processwithreferenceproduct"]: + return "product" + if node_type == "waste": + return "waste" + if node_type == "natural resource": + return "resource" + if node_type == "emission": + return "emission" + else: + return "generic" + +def get_exchange_type(activity_key: tuple, output=False) -> str | None: + if is_node_product(activity_key): + return "substitution" if output else "technosphere" + if is_node_waste(activity_key): + return "-technosphere" if output else "-substitution" + elif is_node_biosphere(activity_key): + return "biosphere" + return None + + +class RelinkDelegate(delegates.StringDelegate): + matched: pd.DataFrame + + def createEditor(self, parent, option, index): + model: ExchangesModel = index.model() + + column = model.column_name(index) + column = "name" if column == "producer" else column + + if column == "product" and model.functional(index): + return super().createEditor(parent, option, index) + + row = model.row(index) + + setup = { + "database": row["database"], + "name": row["producer"], + "product": row["product"], + "categories": row["categories"], + "location": row["location"], + "type": row["_producer_type"], + } + + del setup[column] # remove the column being edited because we are looking for alternatives + + self.matched = app.metadata.match(**setup) + + combo = QtWidgets.QComboBox(parent) + combo.addItems(list(self.matched.get(column, []).astype(str))) + return combo + + def setEditorData(self, editor: QtWidgets.QComboBox, index): + model: ExchangesModel = index.model() + column = model.column_name(index) + column = "name" if column == "producer" else column + + if column == "product" and model.functional(index): + return super().setEditorData(editor, index) + + value = index.data() + if value: + i = editor.findText(str(value)) + if i >= 0: + editor.setCurrentIndex(i) + + def setModelData(self, editor: QtWidgets.QComboBox, model, index): + model: ExchangesModel = index.model() + column = model.column_name(index) + column = "name" if column == "producer" else column + + if column == "product" and model.functional(index): + return super().setModelData(editor, model, index) + + choice = editor.currentIndex() + key = self.matched.iloc[choice].key + row = model.row(index) + + app.actions.ExchangeModify.run( + row.get("_exchange"), + {"input": key} + ) + + +class ExchangesView(widgets.ABTreeView): + """ + A view that displays the exchanges in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + hovered_item (ExchangesItem): The item currently being hovered over. + """ + defaultColumnDelegates = { + "amount": delegates.AbsoluteAmountDelegate, + "allocation_factor": delegates.FloatDelegate, + "substitution_factor": delegates.FloatDelegate, + "unit": delegates.StringDelegate, + "producer": RelinkDelegate, + "location": RelinkDelegate, + "product": RelinkDelegate, + "database": RelinkDelegate, + "categories": RelinkDelegate, + "formula": delegates.NewFormulaDelegate, + "comment": delegates.StringDelegate, + "uncertainty": delegates.UncertaintyDelegate, + } + + class HeaderMenu(widgets.ABMenu): + menuSetup = [ + lambda m: m.setup_view_menu(), + lambda m: m.setup_allocation(), + ] + + def setup_view_menu(self): + table_view: ExchangesView = self.parent() + table_model: ExchangesModel = table_view.model() + + def toggle_slot(action: QtWidgets.QAction): + """ + Toggles the visibility of columns based on the action triggered. + + Args: + action (QtWidgets.QAction): The action triggered. + """ + indices = action.data() + for index in indices: + hidden = table_view.isColumnHidden(index) + table_view.setColumnHidden(index, not hidden) + + # Create the view menu + view_menu = QtWidgets.QMenu(table_view) + view_menu.setTitle("View") + + props_indices = [] + + # Add actions for each column to the view menu + for i, col in enumerate(table_model.columns()): + if col.startswith("property"): + props_indices.append(i) + continue + + action = QtWidgets.QAction(table_model.columns()[i], self) + action.setCheckable(True) + action.setChecked(not table_view.isColumnHidden(i)) + action.setData([i]) + + view_menu.addAction(action) + + # Add a combined action for property columns + if props_indices: + action = QtWidgets.QAction("properties", self) + action.setCheckable(True) + action.setChecked(not table_view.isColumnHidden(props_indices[0])) + action.setData(props_indices) + view_menu.addAction(action) + + # Connect the view menu actions to the toggle slot + view_menu.triggered.connect(toggle_slot) + + # Add the view menu to the context menu + self.addMenu(view_menu) + + def setup_allocation(self): + table_view: ExchangesView = self.parent() + + if database_is_locked(table_view.activity["database"]) or not self.column.startswith("property"): + return + + action = app.actions.ActivityModify.get_QAction(table_view.activity.key, + "allocation", + self.column[9:], + parent=self) + action.setText(f"Allocate by {self.column[9:]}") + self.addAction(action) + + @property + def column(self): + view, model, pos = self.parent(), self.parent().model(), QtGui.QCursor.pos() + col_index = view.columnAt(view.mapFromGlobal(pos).x()) + return model.columns()[col_index] + + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m: m.add(app.actions.ActivityNewProduct, [m.activity.key], + enable=not m.locked and not database_is_legacy(m.activity["database"]) + ), + lambda m: m.add(app.actions.ActivityNewProduct, [m.activity.key], "waste", + enable=not m.locked and not database_is_legacy(m.activity["database"]), + text="Create waste" + ), + lambda m: m.addSeparator(), + lambda m: m.add(app.actions.ExchangeDelete, m.exchanges, enable=bool(m.exchanges) and not m.locked), + lambda m: m.add(app.actions.ExchangeSDFToClipboard, m.exchanges, enable=bool(m.exchanges)), + lambda m: m.add(app.actions.ActivityOpen, [x.input for x in m.exchanges], + enable=bool(m.exchanges), + text="Open processs" if len(m.exchanges) == 1 else "Open processes", + ), + ] + + @property + def locked(self): + return database_is_locked(self.activity["database"]) + + @property + def activity(self): + return self.parent().activity + + @property + def exchanges(self): + indexes = self.parent().selectedIndexes() + exchanges = [i.model().get(i, "_exchange") for i in indexes] + return list(set(exchanges)) + + def __init__(self, parent): + """ + Initializes the ExchangesView. + + Args: + parent (QtWidgets.QWidget): The parent widget. + """ + super().__init__(parent) + self.setSortingEnabled(True) + + # Enable drag and drop + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDragDropMode(QtWidgets.QAbstractItemView.DragDropMode.DragDrop) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + + self.drag_drop_hint = QtWidgets.QLabel("Drag products here to create new exchanges.", self) + fnt = self.drag_drop_hint.font() + fnt.setPointSize(fnt.pointSize() + 2) + fnt.setWeight(QtGui.QFont.Weight.ExtraLight) + self.drag_drop_hint.setFont(fnt) + + # Set up the layout + layout = QtWidgets.QVBoxLayout(self) + layout.addStretch() + layout.addWidget(self.drag_drop_hint, alignment=Qt.AlignmentFlag.AlignCenter) # Center horizontally + layout.addStretch() + + # Set the property delegate + self.propertyDelegate = delegates.PropertyDelegate(self) + self.overlay = widgets.ABDropOverlay(self) + self.overlay.hide() + + @property + def activity(self): + """ + Returns the activity associated with the view. + + Returns: + The activity associated with the view. + """ + return self.parent().parent().parent().activity + + def setDefaultColumnDelegates(self): + """ + Sets the default column delegates for the view. + """ + super().setDefaultColumnDelegates() + + columns = self.model().columns() + for i, col_name in enumerate(columns): + if not col_name.startswith("property_"): + continue + # Set the delegate for property columns + self.setItemDelegateForColumn(i, self.propertyDelegate) + + def startDrag(self, supportedActions: Qt.DropAction) -> None: + """ + Initiates a drag operation with the selected exchanges. + + Args: + supportedActions: The supported drop actions. + """ + if database_is_locked(self.activity["database"]): + return + + super().startDrag(supportedActions) + + +class ExchangesModel(core.ABTreeModel): + """ + A model representing the data for the exchanges. + """ + def __init__(self, tab: ExchangesTab): + super().__init__(parent=tab, enable_sorting=True) + self.tab = tab + + def mimeTypes(self) -> list[str]: + """ + Returns the list of MIME types that this model supports. + + Returns: + list[str]: List of supported MIME types. + """ + return ["application/bw-exchangelist"] + + def mimeData(self, indices: list[QtCore.QModelIndex]) -> core.ABMimeData: + """ + Returns the MIME data for the given indices. + + Args: + indices (list[QtCore.QModelIndex]): The indices to get the MIME data for. + + Returns: + core.ABMimeData: The MIME data containing the exchanges. + """ + data = core.ABMimeData() + exchanges = [self.get(index, "_exchange") for index in indices if index.isValid() and index.column() == 0] + exchanges = [exc for exc in exchanges if exc is not None] + data.setPickleData("application/bw-exchangelist", exchanges) + return data + + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: + """ + Sets the data for the given index. + + Args: + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. + + Returns: + bool: True if the data was set successfully, False otherwise. + """ + if role != Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False + + exchange = row.get("_exchange") + if exchange is None: + return False + + if column_name in ["amount", "formula", "comment"]: + if column_name == "formula" and not str(value).strip(): + app.actions.ExchangeFormulaRemove.run([exchange]) + return True + + app.actions.ExchangeModify.run(exchange, {column_name.lower(): value}) + return True + + if column_name in ["unit", "product", "location", "substitution_factor", "allocation_factor"]: + act = exchange.input + app.actions.ActivityModify.run(act.key, column_name.lower(), value) + return True + + if column_name.startswith("property_"): + # should move this process to a separate action + process = exchange.output + product = exchange.input + + if not isinstance(process, bf.Process) or not isinstance(product, bf.Product): + logger.warning(f"Expected a Process and Product, got {type(process)} and {type(product)} instead.") + return False + + prop_key = column_name[9:] + + prop = process.property_template(prop_key, value) + + props = product.get("properties", {}) + props[prop_key] = prop + + app.actions.ActivityModify.run(product, "properties", props) + return True + + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: + """ + Provides decoration data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide decoration data. + + Returns: + The decoration data for the index. + """ + column_name = self.column_name(index) + + if column_name in ["product", "producer"]: + activity_type = self.get(index, "_producer_type") + if activity_type in ["natural resource", "emission", "inventory indicator", "economic", "social"]: + return icons.qicons.biosphere if column_name == "producer" else None + if activity_type == "processwithreferenceproduct": + return icons.qicons.processproduct if column_name == "producer" else icons.qicons.product + if activity_type in ["product", "process", "multifunctional", "nonfunctional"]: + return icons.qicons.process if column_name == "producer" else icons.qicons.product + if activity_type == "waste": + return icons.qicons.process if column_name == "producer" else icons.qicons.waste + + if column_name == "amount": + formula = self.get(index, "formula") + if pd.isna(formula) or formula is None or formula == "": + return None + return icons.qicons.parameterized + + return None + + def fontData(self, index: QtCore.QModelIndex) -> any: + """ + Provides font data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide font data. + + Returns: + QtGui.QFont: The font data for the index. + """ + if self.substituted(index): + font = QtGui.QFont() + font.setItalic(True) + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font + + if self.functional(index): + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font + + return None + + def indexEditable(self, index): + column_name = self.column_name(index) + database = self.get(index, "_exchange")["output"][0] + + # Prevent editing if the database is locked + if database_is_locked(database): + return False + + functional = self.functional(index) + + # Allow editing for specific keys: "amount", "formula", and "uncertainty". + if column_name in ["amount", "formula", "uncertainty", "comment"]: + return True + + # Allow editing for "unit", "name", and "substitution_factor" if the exchange is functional. + if column_name in ["unit", "product"] and functional: + return True + + # Allow editing for "producer", "location", "categories", and "database" if the exchange is not functional. + if column_name in ["producer", "product", "location", "categories", "database"] and not functional: + return True + + # Allow editing for properties (keys starting with "property_") if the exchange is functional. + if column_name.startswith("property_") and functional: + return True + + # Allow editing for allocation_factor if functional and allocation is manual + if column_name == "allocation_factor" and functional and self.tab.activity.get("allocation") == "manual": + return True + + return False + + def indexDragEnabled(self, index: QModelIndex) -> bool: + return True + + def functional(self, index): + """ + Returns whether the index is functional. + + Args: + index (QtCore.QModelIndex): The index to check. + + Returns: + bool: True if the index is functional, False otherwise. + """ + return self.get(index, "_exchange_type") == "production" + + def substituted(self, index): + """ + Returns whether the index is functional. + + Args: + index (QtCore.QModelIndex): The index to check. + + Returns: + bool: True if the index is functional, False otherwise. + """ + return self.get(index, "_exchange_type") == "substitution" + + def scoped_parameters(self, index): + """ + Returns the scoped parameters for the index. + + Args: + index (QtCore.QModelIndex): The index to get scoped parameters for. + + Returns: + list: A list of scoped parameters for the index. + """ + exchange = self.get(index, "_exchange") + return parameters_in_scope(exchange.output) + \ No newline at end of file diff --git a/activity_browser/app/pages/activity_details/graph_tab.py b/activity_browser/app/pages/activity_details/graph_tab.py new file mode 100644 index 000000000..8b44a7f86 --- /dev/null +++ b/activity_browser/app/pages/activity_details/graph_tab.py @@ -0,0 +1,333 @@ +import json +import os +from loguru import logger + +from qtpy import QtWebChannel, QtWebEngineWidgets, QtWidgets +from qtpy.QtCore import QObject, Qt, QUrl, Signal, SignalInstance, Slot + +import bw2data as bd +import bw_functional as bf + +from activity_browser import static, app +from activity_browser.bwutils.commontasks import refresh_node, database_is_locked +from activity_browser.ui import widgets +from .exchanges_tab import get_exchange_type + + + + +class GraphTab(QtWidgets.QWidget): + """ + A widget that displays a graph related to a specific activity. + + Attributes: + activity (tuple | int | bd.Node): The activity to display the graph for. + expanded_nodes (set): A set of node IDs that are expanded in the graph. + button (QtWidgets.QPushButton): A button to trigger synchronization. + bridge (Bridge): A bridge object for communication between Python and JavaScript. + backend (GraphBackend): A backend object for communication between Python and JavaScript. + url (QUrl): The URL of the HTML file to display. + channel (QtWebChannel.QWebChannel): A web channel for communication between Python and JavaScript. + page (Page): A web engine page to display the HTML content. + view (QtWebEngineWidgets.QWebEngineView): A web engine view to display the HTML content. + """ + def __init__(self, activity, parent=None): + """ + Initializes the GraphTab widget. + + Args: + activity (tuple | int | bd.Node): The activity to display the graph for. + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + self.setAcceptDrops(True) + + self.activity = refresh_node(activity) + self.expanded_nodes = {self.activity.id} + + self.button = QtWidgets.QPushButton("CLICK ME") + self.button.clicked.connect(self.sync) + + self.bridge = Bridge(self) + self.backend = GraphBackend(self) + self.url = QUrl.fromLocalFile(os.path.join(static.__path__[0], "activity_graph.html")) + + self.channel = QtWebChannel.QWebChannel(self) + self.channel.registerObject("bridge", self.bridge) + self.channel.registerObject("backend", self.backend) + + self.page = Page() + self.page.setWebChannel(self.channel) + + self.view = GraphView(self) + self.view.setPage(self.page) + self.view.setUrl(self.url) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.view) + self.setLayout(layout) + + self.bridge.ready.connect(self.sync) + + def sync(self): + """ + Synchronizes the widget with the current state of the activity. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.activity = refresh_node(self.activity) + json = self.build_json() + self.bridge.update_graph.emit(json) + + def build_json(self): + """ + Builds a JSON representation of the graph. + + Returns: + str: The JSON representation of the graph. + """ + nodes = [] + edges = [] + + collapsed_functions = set() + for node_id in self.expanded_nodes: + node = bd.get_node(id=node_id) + excs = list(node.exchanges()) + function_nodes = [exc.input for exc in excs if exc["type"] == "production"] + functions = [] + + for fn_node in function_nodes: + functions.append({ + "id": f"bw{fn_node.id}", + "name": fn_node._document.product if fn_node._document.product else fn_node["name"] + }) + excs.extend(fn_node.upstream()) + + nodes.append({ + "id": f"bw{node.id}", + "name": node["name"], + "functions": functions, + "type": "expanded_node" + }) + + for exc in excs: + if exc["type"] in ["production", "biosphere"]: + continue + processor = get_processor_from_exchange(exc) + + source_id = processor.id + target_id = exc.output.id + + if source_id not in self.expanded_nodes: + source_id = exc.input.id + collapsed_functions.add(source_id) + + if target_id not in self.expanded_nodes: + collapsed_functions.add(target_id) + + edges.append({ + "source_id": f"bw{source_id}", + "target_id": f"bw{exc.output.id}", + "function_id": f"bw{exc.input.id}", + }) + + for node_id in collapsed_functions: + fn_node = bd.get_node(id=node_id) + nodes.append({ + "id": f"bw{node_id}", + "name": fn_node._document.product if fn_node._document.product else fn_node["name"], + "functions": [], + "type": "collapsed_function" + }) + + full = { + "nodes": nodes, + "edges": edges, + } + + return json.dumps(full) + + def expand_node(self, node_id: str): + """ + Expands a node in the graph. + + Args: + node_id (str): The ID of the node to expand. + """ + node_id = int(node_id) # JS shenanigans can't deal with 64 bit strings + node = bd.get_node(id=node_id) + if isinstance(node, bf.Product): + node = bd.get_node(key=node["processor"]) + self.expanded_nodes.add(node.id) + self.sync() + + def collapse_node(self, node_id: str): + """ + Collapses a node in the graph. + + Args: + node_id (str): The ID of the node to collapse. + """ + node_id = int(node_id) # JS shenanigans can't deal with 64 bit strings + if self.activity.id == node_id: + return + self.expanded_nodes.remove(int(node_id)) + self.sync() + + +def get_processor_from_exchange(exchange): + """ + Gets the processor from an exchange. + + Args: + exchange: The exchange to get the processor from. + + Returns: + The processor of the exchange. + """ + source = exchange.input + processors = list(source.upstream(kinds=["production"])) + if len(processors) > 1: + logger.warning("Multiple processors, only taking first one") + processor = processors[0] + return processor.output + + +class GraphView(QtWebEngineWidgets.QWebEngineView): + + def __init__(self, parent=None): + super().__init__(parent) + self.setAcceptDrops(True) + self.setContextMenuPolicy(Qt.PreventContextMenu) + self.overlay = None + + def dragEnterEvent(self, event): + """ + Handles the drag enter event. + + Args: + event: The drag enter event. + """ + if database_is_locked(self.parent().activity["database"]): + return + + if event.mimeData().hasFormat("application/bw-nodekeylist"): + self.overlay = widgets.ABDropOverlay(self) + self.overlay.show() + event.accept() + + def dragLeaveEvent(self, event): + """ + Handles the drag leave event. + + Args: + event: The drag leave event. + """ + # Reset the palette on drag leave + self.overlay.deleteLater() + + def dropEvent(self, event): + """ + Handles the drop event. + + Args: + event: The drop event. + """ + logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") + # Reset the palette on drop + self.overlay.deleteLater() + + keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") + exchanges = {"technosphere": set(), "biosphere": set()} + + for key in keys: + if exc_type := get_exchange_type(key): + exchanges[exc_type].add(key) + + # Run the action for new exchanges + for exc_type, keys in exchanges.items(): + app.actions.ExchangeNew.run(keys, self.parent().activity.key, exc_type) + + +class GraphBackend(QObject): + """ + A backend object for communication between Python and JavaScript. + This object is exposed to the JavaScript side and provides methods + that can be called from JavaScript to control the graph. + """ + def __init__(self, graph_tab: GraphTab, parent=None): + """ + Initializes the GraphBackend object. + + Args: + graph_tab (GraphTab): The GraphTab widget this backend is associated with. + parent (QObject, optional): The parent object. Defaults to None. + """ + super().__init__(parent) + self.graph_tab = graph_tab + + @Slot(str) + def expand_node(self, node_id: str): + """ + Expands a node in the graph. + + Args: + node_id (str): The ID of the node to expand. + """ + self.graph_tab.expand_node(node_id) + + @Slot(str) + def collapse_node(self, node_id: str): + """ + Collapses a node in the graph. + + Args: + node_id (str): The ID of the node to collapse. + """ + self.graph_tab.collapse_node(node_id) + + +class Bridge(QObject): + """ + A bridge for communication between Python and JavaScript. + + Attributes: + update_graph (SignalInstance): A signal to update the graph. + ready (SignalInstance): A signal indicating that the bridge is ready. + """ + update_graph: SignalInstance = Signal(str) + ready: SignalInstance = Signal() + + @Slot() + def is_ready(self): + """ + Emits the ready signal. + """ + self.ready.emit() + + +class Page(QtWebEngineWidgets.QWebEnginePage): + """ + A web engine page to display the HTML content. + + Methods: + javaScriptConsoleMessage: Logs JavaScript console messages. + """ + def javaScriptConsoleMessage(self, level: QtWebEngineWidgets.QWebEnginePage.JavaScriptConsoleMessageLevel, message: str, line: str, _: str): + """ + Logs JavaScript console messages. + + Args: + level (QtWebEngineWidgets.QWebEnginePage.JavaScriptConsoleMessageLevel): The message level. + message (str): The message content. + line (str): The line number. + _ (str): Unused parameter. + """ + if level == QtWebEngineWidgets.QWebEnginePage.InfoMessageLevel: + logger.info(f"JS Info (Line {line}): {message}") + elif level == QtWebEngineWidgets.QWebEnginePage.WarningMessageLevel: + logger.warning(f"JS Warning (Line {line}): {message}") + elif level == QtWebEngineWidgets.QWebEnginePage.ErrorMessageLevel: + logger.error(f"JS Error (Line {line}): {message}") + else: + logger.debug(f"JS Log (Line {line}): {message}") diff --git a/activity_browser/app/pages/activity_details/parameters_tab.py b/activity_browser/app/pages/activity_details/parameters_tab.py new file mode 100644 index 000000000..dd055fbb3 --- /dev/null +++ b/activity_browser/app/pages/activity_details/parameters_tab.py @@ -0,0 +1,387 @@ +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt +from loguru import logger + +import pandas as pd +import bw2data as bd + +from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, Group, ParameterBase + +from activity_browser import app +from activity_browser.ui import widgets, icons, delegates, core +from activity_browser.bwutils.commontasks import refresh_node, refresh_parameter, parameters_in_scope, database_is_locked, node_group +from activity_browser.bwutils.utils import Parameter + + +class ParametersTab(QtWidgets.QWidget): + """ + A widget that displays parameters related to a specific activity. + + Attributes: + activity (tuple | int | bd.Node): The activity to display parameters for. + model (ParametersModel): The model containing the data for the parameters. + view (ParametersView): The view displaying the parameters. + """ + def __init__(self, activity, parent=None): + """ + Initializes the ParametersTab widget. + + Args: + activity (tuple | int | bd.Node): The activity to display parameters for. + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + self.activity = refresh_node(activity) + + self.view = ParametersView(self) + self.view.setSortingEnabled(False) + self.view.setUniformRowHeights(True) + + self.model = ParametersModel(tab=self) + self.view.setModel(self.model) + + self.build_layout() + self.connect_signals() + + def build_layout(self): + """ + Builds the layout of the widget. + """ + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 10, 0, 1) + layout.addWidget(self.view) + + self.setLayout(layout) + + def connect_signals(self): + """ + Connects signals to their respective slots. + """ + app.signals.parameter.changed.connect(self.sync) + app.signals.parameter.recalculated.connect(self.sync) + app.signals.parameter.deleted.connect(self.sync) + + def sync(self): + """ + Synchronizes the widget with the current state of the activity. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + df = self.build_df() + self.model.set_dataframe(df, group=["_param_type", "_scope"]) + self.view.expandAll() + + self.view.resizeColumnToContents(1) + self.view.resizeColumnToContents(3) + self.view.resizeColumnToContents(4) + + def build_df(self) -> pd.DataFrame: + """ + Builds a DataFrame from all parameters in the project. + + Returns: + pd.DataFrame: The DataFrame containing the parameters data. + """ + translated = [] + + # Project parameters + for param in ProjectParameter.select(): + row = self._parameter_to_row(param) + translated.append(row) + + translated.append({ + "name": "New parameter...", + "_group": "project", + "_param_type": "project", + "_class": "new", + }) + + # Database parameters + db_params = DatabaseParameter.select() + db_name = self.activity["database"] + + for param in db_params.where(DatabaseParameter.database == db_name): + row = self._parameter_to_row(param, db_name, db_name) + translated.append(row) + + if not database_is_locked(db_name): + translated.append({ + "name": "New parameter...", + "_scope": db_name, + "_database": db_name, + "_group": db_name, + "_param_type": "database", + "_class": "new", + }) + + # Activity parameters + act_params = ActivityParameter.select() + group_name = node_group(self.activity) or str(self.activity.id) + + for param in act_params.where(ActivityParameter.group == group_name): + row = self._parameter_to_row(param, f"Group: {group_name}", param.database) + translated.append(row) + + if not database_is_locked(self.activity["database"]): + translated.append({ + "name": "New parameter...", + "_scope": f"Group: {group_name}", + "_database": self.activity["database"], + "_group": group_name, + "_param_type": "activity", + "_class": "new", + }) + + columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_database", "_group", + "_param_type", "_class"] + df = pd.DataFrame(translated, columns=columns) + + df["_activity"] = [self.activity for i in range(len(df))] + return df + + def _parameter_to_row(self, param, scope_label: str = None, database: str = None) -> dict: + """ + Converts a parameter to a row dictionary. + + Args: + param: The parameter to convert (ProjectParameter, DatabaseParameter, or ActivityParameter). + scope_label: The label for the scope (e.g., "Current project", "Database: ecoinvent"). + database: The database name (None for project parameters). + + Returns: + dict: A dictionary representing the parameter row. + """ + data = param.dict + + # Create Parameter wrapper + if isinstance(param, ProjectParameter): + parameter = Parameter(param.name, "project", data.get("amount"), data, "project") + group = "project" + param_type = "project" + elif isinstance(param, DatabaseParameter): + parameter = Parameter(param.name, param.database, data.get("amount"), data, "database") + group = param.database + param_type = "database" + elif isinstance(param, ActivityParameter): + parameter = Parameter(param.name, param.group, data.get("amount"), data, "activity") + group = param.group + param_type = "activity" + else: + raise ValueError(f"Unknown parameter type: {type(param)}") + + row = { + "name": parameter.name, + "amount": parameter.amount, + "uncertainty": parameter.uncertainty, + "formula": data.get("formula"), + "comment": data.get("comment"), + "_param_type": param_type, + "_parameter": parameter, + "_scope": scope_label, + "_database": database, + "_group": group, + "_class": "instantiated", + } + + return row + + +class ParametersView(widgets.ABTreeView): + """ + A view that displays the parameters in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + """ + defaultColumnDelegates = { + "amount": delegates.FloatDelegate, + "name": delegates.StringDelegate, + "formula": delegates.NewFormulaDelegate, + "comment": delegates.StringDelegate, + "uncertainty": delegates.UncertaintyDelegate, + } + + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m: m.add(app.actions.ParameterDelete, m.parameters, enable=bool(m.parameters) and not m.locked), + ] + + @property + def locked(self): + table_view: ParametersView = self.parent() + return database_is_locked(table_view.activity["database"]) + + @property + def activity(self): + table_view: ParametersView = self.parent() + return table_view.activity + + @property + def parameters(self): + table_view: ParametersView = self.parent() + table_model: ParametersModel = table_view.model() + + selected_indices = table_view.selectedIndexes() + params = table_model.values_from_indices("_parameter", selected_indices) + # Convert to peewee models + return [p.to_peewee_model() for p in params if p is not None] + + @property + def activity(self): + """ + Returns the activity associated with the view. + + Returns: + The activity associated with the view. + """ + return self.parent().activity + + +class ParametersModel(core.ABTreeModel): + """ + A model representing the data for the parameters. + """ + def __init__(self, tab: ParametersTab): + super().__init__(parent=tab) + self.tab = tab + + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: + """ + Sets the data for the given index. + + Args: + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. + + Returns: + bool: True if the data was set successfully, False otherwise. + """ + if role != Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False + + # Handle "New parameter..." rows + if row.get("_class") == "new": + if column_name != "name" or value == "": + return False + + parameter = Parameter( + name=value, + group=row.get("_group"), + param_type=row.get("_param_type") + ) + + app.actions.ParameterNewFromParameter.run(parameter) + return True + + # Handle regular parameter edits + parameter = row.get("_parameter") + if parameter is None: + return False + + if column_name in ["amount", "formula", "name", "comment"]: + parameter = refresh_parameter(parameter) + app.actions.ParameterModify.run(parameter, column_name, value) + + if column_name == "uncertainty": + parameter = refresh_parameter(parameter) + app.actions.ParameterUncertaintyModify.run(parameter.to_peewee_model(), uncertainty_dict=value) + + return True + + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: + """ + Provides decoration data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide decoration data. + + Returns: + The decoration data for the index. + """ + column_name = self.column_name(index) + + if column_name == "amount": + formula = self.get(index, "formula") + formula = isinstance(formula, str) and formula.strip() + + return icons.qicons.parameterized if formula else icons.qicons.empty + + return None + + def fontData(self, index: QtCore.QModelIndex) -> any: + """ + Provides font data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide font data. + + Returns: + QtGui.QFont: The font data for the index. + """ + param_class = self.get(index, "_class") + if param_class == "new": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.ExtraLight) + return font + + if param_class == "broken": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.Bold) + return font + + return None + + def indexEditable(self, index: QtCore.QModelIndex) -> bool: + """ + Returns whether the index is editable. + + Args: + index (QtCore.QModelIndex): The index to check. + + Returns: + bool: True if the index is editable, False otherwise. + """ + column_name = self.column_name(index) + + # Check if database is locked + database = self.get(index, "_database") + if not pd.isna(database) and database_is_locked(database): + return False + + # Prevent editing broken parameters + if self.get(index, "_class") == "broken": + return False + + # Allow editing for specific columns + if column_name in ["formula", "uncertainty", "name", "comment"]: + return True + + if column_name == "amount" and not self.get(index, "formula"): + return True + + return False + + def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: + """ + Returns the parameters in scope of the parameter at the given index. + + Args: + index (QtCore.QModelIndex): The index to get scoped parameters for. + + Returns: + dict: The parameters in scope. + """ + parameter = self.get(index, "_parameter") + if parameter is None or isinstance(parameter, float): # NaN check + return {} + + return parameters_in_scope(parameter=parameter) diff --git a/activity_browser/app/pages/calculation_setup/__init__.py b/activity_browser/app/pages/calculation_setup/__init__.py new file mode 100644 index 000000000..eb8edb84e --- /dev/null +++ b/activity_browser/app/pages/calculation_setup/__init__.py @@ -0,0 +1 @@ +from .calculation_setup import CalculationSetupPage \ No newline at end of file diff --git a/activity_browser/app/pages/calculation_setup/calculation_setup.py b/activity_browser/app/pages/calculation_setup/calculation_setup.py new file mode 100644 index 000000000..17e2dc0f6 --- /dev/null +++ b/activity_browser/app/pages/calculation_setup/calculation_setup.py @@ -0,0 +1,93 @@ +from qtpy import QtWidgets +from loguru import logger + +from activity_browser import app +from activity_browser.ui import widgets, icons + +from .scenario_section import ScenarioSection +from .functional_unit_section import FunctionalUnitSection +from .impact_category_section import ImpactCategorySection + + +class CalculationSetupPage(QtWidgets.QWidget): + + def __init__(self, cs_name: str, parent=None): + super().__init__(parent) + self.setObjectName(cs_name) + + self.calculation_setup_name = cs_name + + self.type_dropdown = QtWidgets.QComboBox() + self.type_dropdown.addItems(["Standard", "Scenario"]) + + self.run_button = QtWidgets.QPushButton("Run", self) + self.run_button.setIcon(icons.qicons.forward) + self.run_button.setStyleSheet("background-color: #57965C;") + + self.functional_unit_section = FunctionalUnitSection(cs_name, self) + self.impact_category_section = ImpactCategorySection(cs_name, self) + self.scenario_section = ScenarioSection(self) + self.scenario_section.hide() + + # Build the layout of the widget + self.build_layout() + self.sync() + self.connect_signals() + + def build_layout(self): + """ + Builds the layout of the widget. + """ + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 3, 0, 0) + + top_layout = QtWidgets.QHBoxLayout() + top_layout.setContentsMargins(0, 0, 10, 0) + top_layout.addWidget(widgets.ABLabel.demiBold(" Functional Units:", self)) + top_layout.addStretch() + top_layout.addWidget(self.type_dropdown) + top_layout.addWidget(self.run_button) + + # Add fu label and view to the layout + layout.addLayout(top_layout) + layout.addWidget(self.functional_unit_section) + + # Add ic label and view to the layout + layout.addWidget(widgets.ABLabel.demiBold(" Impact Categories:", self)) + layout.addWidget(self.impact_category_section) + + # Add scenario label and view to the layout + + layout.addWidget(self.scenario_section) + + # Set the layout for the widget + self.setLayout(layout) + + def connect_signals(self): + app.signals.project.changed.connect(self.sync) + app.signals.meta.calculation_setups_changed.connect(self.sync) + + self.type_dropdown.currentTextChanged.connect(self.type_switch) + self.run_button.released.connect(self.run_calculation) + + def sync(self) -> None: + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.functional_unit_section.sync() + self.impact_category_section.sync() + + def type_switch(self, calculation_type: str): + if calculation_type == "Standard": + self.scenario_section.hide() + elif calculation_type == "Scenario": + self.scenario_section.show() + else: + raise ValueError(f"Unknown calculation type: {calculation_type}") + + def run_calculation(self): + if self.type_dropdown.currentText() == "Standard": + app.actions.CSCalculate.run(self.calculation_setup_name) + elif self.type_dropdown.currentText() == "Scenario": + scenario_data = self.scenario_section.scenario_dataframe() + app.actions.CSCalculate.run(self.calculation_setup_name, scenario_data) + diff --git a/activity_browser/app/pages/calculation_setup/functional_unit_section.py b/activity_browser/app/pages/calculation_setup/functional_unit_section.py new file mode 100644 index 000000000..baf8be3cb --- /dev/null +++ b/activity_browser/app/pages/calculation_setup/functional_unit_section.py @@ -0,0 +1,241 @@ +from qtpy import QtWidgets, QtCore +from qtpy.QtCore import Qt +from loguru import logger + +import bw2data as bd +import pandas as pd + +from activity_browser import app +from activity_browser.ui import widgets, icons, delegates, core +from activity_browser.bwutils.commontasks import is_node_product_or_waste + + +class FunctionalUnitSection(QtWidgets.QWidget): + def __init__(self, calculation_setup_name: str, parent=None): + super().__init__(parent) + + self.calculation_setup_name = calculation_setup_name + self.calculation_setup = bd.calculation_setups.get(self.calculation_setup_name) + + self.view = FunctionalUnitView(self) + self.model = FunctionalUnitModel(parent=self) + self.view.setModel(self.model) + + self.build_layout() + + def build_layout(self): + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.view) + self.setLayout(layout) + + def sync(self): + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + try: + self.calculation_setup = bd.calculation_setups[self.calculation_setup_name] + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) + except KeyError: + self.parent().close() + self.parent().deleteLater() + + def build_df(self): + keys, amounts = [], [] + cols = ["unit", "name", "product", "location", "database", "processor", "type"] + + for fu in self.calculation_setup.get("inv", []): + for key, amount in fu.items(): + keys.append(key) + amounts.append(amount) + + act_df = app.metadata.get_metadata(keys, cols) + act_df["amount"] = amounts + act_df["_activity_key"] = keys + act_df["_cs_name"] = self.calculation_setup_name + + act_df["_processor_key"] = act_df["processor"] + act_df["_processor_key"] = act_df["_processor_key"].fillna(act_df["_activity_key"]) + + # Retrieve metadata for unique processor keys, focusing on the "name" column. + processor_df = app.metadata.get_metadata(act_df["_processor_key"].unique(), ["name"]) + + # Flatten the index of the processor DataFrame to ensure compatibility with merging. + processor_df.index = processor_df.index.to_flat_index() + + # Merge the processor keys from the activity DataFrame with the processor metadata. + processor_df = pd.merge(act_df["_processor_key"].astype(object), processor_df, "right", + left_on="_processor_key", right_index=True, ) + + # Add a column for function keys by flattening the index of the processor DataFrame. + processor_df["function_keys"] = processor_df.index.to_flat_index() + + # Remove duplicate rows from the processor DataFrame to ensure uniqueness. + processor_df = processor_df.drop_duplicates() + + # Add the "process" column to the activity DataFrame using the processor names. + act_df["process"] = processor_df["name"] + + # Use "product" if available otherwise use "name" + act_df.update(act_df["product"].rename("name")) + act_df["product"] = act_df["name"] + + act_df.rename({"type": "_type"}, axis="columns", inplace=True) + + cols = ["amount", "unit", "product", "process", "database", "location", "_processor_key", "_activity_key", "_cs_name", "_type"] + + return act_df[cols].reset_index(drop=True) + + +class FunctionalUnitView(widgets.ABTreeView): + defaultColumnDelegates = { + "amount": delegates.AmountDelegate + } + + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m, p: m.add(app.actions.ActivityOpen, p.selected_processes(), + text="Open process" if len(p.selected_processes()) == 1 else "Open processes", + enable=len(p.selected_processes()) > 0 + ), + lambda m: m.addSeparator(), + lambda m, p: m.add(app.actions.CSDeleteFunctionalUnit, p.cs_name(), p.selected_row_indices(), + text="Delete Functional Unit" if len(p.selected_processes()) == 1 else "Delete Functional Units", + enable=len(p.selected_processes()) > 0 + ), + + ] + + def __init__(self, parent=None): + super().__init__(parent) + self.setAcceptDrops(True) + self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + + def mouseDoubleClickEvent(self, event) -> None: + """ + Handles the mouse double click event to open the selected activities. + + Args: + event: The mouse double click event. + """ + index = self.indexAt(event.pos()) + if index.column() == 1: # Prevent action on amount column + return super().mouseDoubleClickEvent(event) + + if self.selectedIndexes(): + activities = self.model().values_from_indices("_processor_key", self.selectedIndexes()) + app.actions.ActivityOpen.run(list(set(activities))) + + return None + + def dragMoveEvent(self, event) -> None: + pass + + def dragEnterEvent(self, event): + if event.mimeData().hasFormat("application/bw-nodekeylist"): + keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") + for key in keys: + if not is_node_product_or_waste(key): + keys.remove(key) + + if not keys: + return + + event.accept() + + def dropEvent(self, event) -> None: + event.accept() + cs_name = self.parent().calculation_setup_name + + keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") + for key in keys.copy(): + if not is_node_product_or_waste(key): + keys.remove(key) + + app.actions.CSAddFunctionalUnit.run(cs_name, keys) + + def selected_row_indices(self): + return [i.row() for i in super().selectedIndexes()] + + def cs_name(self): + return self.parent().calculation_setup_name + + def selected_processes(self): + return list(set(self.model().values_from_indices("_processor_key", self.selectedIndexes()))) + + +class FunctionalUnitModel(core.ABTreeModel): + """ + A model representing the data for the functional units. + """ + + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: + """ + Sets the data for the given index. + + Args: + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. + + Returns: + bool: True if the data was set successfully, False otherwise. + """ + if role != Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False + + if column_name == "amount": + cs_name = row.get("_cs_name") + app.actions.CSChangeFunctionalUnit.run(cs_name, index.row(), value) + return True + + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: + """ + Provides decoration data (icons) for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide decoration data. + + Returns: + The decoration data (icon) for the index. + """ + column_name = self.column_name(index) + + if column_name == "product": + product_type = self.get(index, "_type") + if product_type == "waste": + return icons.qicons.waste + elif product_type == "processwithreferenceproduct": + return icons.qicons.processproduct + else: + return icons.qicons.product + elif column_name == "process": + return icons.qicons.process + + return None + + def indexEditable(self, index: QtCore.QModelIndex) -> bool: + """ + Returns whether the index is editable. + + Args: + index (QtCore.QModelIndex): The index to check. + + Returns: + bool: True if the index is editable, False otherwise. + """ + column_name = self.column_name(index) + + if column_name == "amount": + return True + + return False diff --git a/activity_browser/app/pages/calculation_setup/impact_category_section.py b/activity_browser/app/pages/calculation_setup/impact_category_section.py new file mode 100644 index 000000000..2fcda22f8 --- /dev/null +++ b/activity_browser/app/pages/calculation_setup/impact_category_section.py @@ -0,0 +1,98 @@ +from qtpy import QtWidgets +from loguru import logger + +import bw2data as bd +import pandas as pd + +from activity_browser import app +from activity_browser.ui import widgets, delegates, core + + +class ImpactCategorySection(QtWidgets.QWidget): + def __init__(self, calculation_setup_name: str, parent=None): + super().__init__(parent) + + self.calculation_setup_name = calculation_setup_name + self.calculation_setup = bd.calculation_setups.get(self.calculation_setup_name) + + self.view = ImpactCategoryView(self) + self.model = ImpactCategoryModel(parent=self) + self.view.setModel(self.model) + + self.build_layout() + + def build_layout(self): + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.view) + self.setLayout(layout) + + def sync(self): + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + try: + self.calculation_setup = bd.calculation_setups[self.calculation_setup_name] + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) + except KeyError: + self.parent().close() + self.parent().deleteLater() + + def build_df(self): + data = [bd.methods.get(method_name) for method_name in self.calculation_setup.get("ia", [])] + df = pd.DataFrame(data, columns=["name", "unit", "num_cfs"]) + + df["name"] = self.calculation_setup.get("ia", []) + df["_cs_name"] = self.calculation_setup_name + + cols = ["name", "unit", "num_cfs", "_cs_name"] + + return df[cols] + + +class ImpactCategoryView(widgets.ABTreeView): + defaultColumnDelegates = { + "name": delegates.StringDelegate + } + + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m, p: m.add(app.actions.CSDeleteImpactCategory, m.cs_name, m.selected_ics, + text="Delete Impact Category" if len(m.selected_ics) == 1 else "Delete Impact Categories", + enable=len(m.selected_ics) > 0 + ), + ] + + @property + def selected_ics(self): + return list(set([index.row() for index in self.parent().selectedIndexes()])) + + @property + def cs_name(self): + return self.parent().parent().calculation_setup_name + + def __init__(self, parent=None): + super().__init__(parent) + self.setAcceptDrops(True) + self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + + def dragMoveEvent(self, event) -> None: + pass + + def dragEnterEvent(self, event): + if event.mimeData().hasFormat("application/bw-methodnamelist"): + event.accept() + + def dropEvent(self, event) -> None: + event.accept() + cs_name = self.parent().calculation_setup_name + method_names = event.mimeData().retrievePickleData("application/bw-methodnamelist") + app.actions.CSAddImpactCategory.run(cs_name, method_names) + + +class ImpactCategoryModel(core.ABTreeModel): + """ + A model representing the data for the impact categories. + """ + pass diff --git a/activity_browser/app/pages/calculation_setup/scenario_section.py b/activity_browser/app/pages/calculation_setup/scenario_section.py new file mode 100644 index 000000000..ff7040fef --- /dev/null +++ b/activity_browser/app/pages/calculation_setup/scenario_section.py @@ -0,0 +1,580 @@ +from loguru import logger +from pathlib import Path + +from qtpy import QtWidgets +from qtpy.QtCore import Qt + +import pandas as pd +import bw2data as bd +from activity_browser.bwutils import superstructure as ss + +from activity_browser import app +from activity_browser.ui import icons, widgets + + + +class ScenarioSection(QtWidgets.QWidget): + max_tables = 5 + + """Special kind of QWidget that contains one or more tables side by side.""" + + def __init__(self, parent=None): + super().__init__(parent) + + self.tables = [] + self._scenario_dataframe = pd.DataFrame() + + # set up the control buttons + self.get_template_btn = app.actions.SaveParametersToExcel.get_QButton() + self.get_template_btn.setText("Parameter template...") + + self.table_btn = QtWidgets.QPushButton("Add scenarios...", self) + + self.save_scenario = QtWidgets.QPushButton("Save to file...", self) + self.save_scenario.setDisabled(True) + + # set up the combination buttons + + # initiate the combine scenarios button + self.product_choice = QtWidgets.QRadioButton("Combine scenarios", self) + self.product_choice.setChecked(True) + + # initiate the extend scenarios button + self.addition_choice = QtWidgets.QRadioButton("Extend scenarios", self) + + # group them and make them exclusive + self.combine_group = QtWidgets.QButtonGroup(self) + self.combine_group.setExclusive(True) + self.combine_group.addButton(self.product_choice) + self.combine_group.addButton(self.addition_choice) + + # orient them horizontally + input_field_layout = QtWidgets.QHBoxLayout() + input_field_layout.setContentsMargins(0, 0, 0, 0) + input_field_layout.addWidget(self.product_choice) + input_field_layout.addWidget(self.addition_choice) + + # add the border and hide until further notice + self.group_box = QtWidgets.QGroupBox() + self.group_box.setLayout(input_field_layout) + self.group_box.setDisabled(True) + + # combining all into the tool row + tool_row = QtWidgets.QHBoxLayout() + tool_row.setContentsMargins(0, 0, 0, 0) + tool_row.addSpacing(10) + + tool_row.addWidget(widgets.ABLabel.demiBold(" Scenarios:", self)) + tool_row.addStretch() + tool_row.addWidget(self.get_template_btn) + tool_row.addWidget(self.table_btn) + tool_row.addWidget(self.save_scenario) + tool_row.addWidget(self.group_box) + + # layout for the different scenario tables that can be added + self.scenario_tables = QtWidgets.QHBoxLayout() + + # statistics at the bottom of the widget + self.stats_widget = QtWidgets.QLabel() + self.update_stats() + + # construct the full layout + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 0, 10, 0) + layout.addLayout(tool_row) + layout.addLayout(self.scenario_tables) + layout.addStretch(1) + layout.addWidget(self.stats_widget) + self.setLayout(layout) + + self.connect_signals() + + def connect_signals(self) -> None: + app.signals.project.changed.connect(self.clear_tables) + app.signals.project.changed.connect(self.can_add_table) + + self.table_btn.clicked.connect(self.add_table) + self.table_btn.clicked.connect(self.can_add_table) + self.save_scenario.clicked.connect(self.save_action) + self.combine_group.buttonClicked.connect(self.toggle_combine_type) + + def update_stats(self) -> None: + """Update the statistics at the bottom of the widget""" + n_scenarios = len(self._scenario_dataframe.columns) + n_flows = len(self._scenario_dataframe) + + stats = f"Total number of scenarios: {n_scenarios} | Total number of variable flows: {n_flows}" + self.stats_widget.setText(stats) + + def toggle_combine_type(self) -> None: + """Called by signal when the combine type is switched by the user""" + try: + # try to update the combined dataframe + self.combined_dataframe() + except: + # revert when an exception occurs + type = self.get_combine_type() + if type == "product": + self.addition_choice.setChecked(True) + if type == "addition": + self.product_choice.setChecked(True) + + def get_combine_type(self) -> str: + """Return the type of combination the user wants to do""" + if self.product_choice.isChecked(): + return "product" + elif self.addition_choice.isChecked(): + return "addition" + + def scenario_dataframe(self) -> pd.DataFrame: + return self._scenario_dataframe + + def scenario_names(self, idx: int) -> list: + if idx > len(self.tables): + return [] + return ss.scenario_names_from_df(self.tables[idx]) + + def combined_dataframe(self, skip_checks: bool = False) -> None: + """Updates scenario dataframe to contain the combined scenarios of multiple tables.""" + # if there are no tables currently, set the dataframe to be empty + if not self.tables: + self._scenario_dataframe = pd.DataFrame() + self.update_stats() + return + + # if the tables are empty, set the dataframe to be empty + data = [df for df in (t.dataframe for t in self.tables) if not df.empty] + if not data: + self._scenario_dataframe = pd.DataFrame() + self.update_stats() + return + + # check what kind of combination the user wants to do + kind = self.get_combine_type() + + # combine the data using SuperstructureManager and update the dataframe + manager = ss.SuperstructureManager(*data) + self._scenario_dataframe = manager.combined_data(kind, skip_checks) + + # update the stats at the bottom of the widget + self.update_stats() + + def add_table(self) -> None: + """Add a new table widget to the widget and add to the list of tables""" + new_idx = len(self.tables) + widget = ScenarioImportWidget(new_idx, self) + self.tables.append(widget) + self.scenario_tables.addWidget(widget) + self.updateGeometry() + + def remove_table(self, index: int) -> None: + """Remove the table widget at the provided index""" + # remove from the self.tables list and the layout + table_widget = self.tables.pop(index) + self.scenario_tables.removeWidget(table_widget) + + # update the other widgets with new indices + for i, widget in enumerate(self.tables): + widget.index = i + + # if there was data in the widget, recalculate the combined DF + if not table_widget.dataframe.empty: + self.combined_dataframe(skip_checks=True) + + # free up the memory + table_widget.deleteLater() + + # update save_scenario button + if not self.tables: + self.save_scenario.setDisabled(True) + self.updateGeometry() + + def clear_tables(self) -> None: + """Clear all scenario tables in certain cases (eg. project change).""" + for w in self.tables: + self.scenario_tables.removeWidget(w) + w.deleteLater() + self.tables = [] + self.save_scenario.setDisabled(True) + self.updateGeometry() + self.combined_dataframe() + + def updateGeometry(self): + self.group_box.setDisabled(len(self.tables) <= 1) + # Make sure that scenario tables are equally balanced within the box. + if self.tables: + table_width = self.width() / len(self.tables) + for table in self.tables: + table.setMaximumWidth(table_width) + super().updateGeometry() + + def can_add_table(self) -> None: + """Use this to set a hardcoded limit on the amount of scenario tables + a user can add. + """ + self.table_btn.setEnabled(len(self.tables) < self.max_tables) + + def save_action(self) -> None: + """Creates and saves to file (.xlsx, or .csv) the scenario dataframe after the loaded scenarios have been + merged. Will not contain duplicates. Will not contain self-referential technosphere flows. + + Triggered by a signal from ScenarioImportPanel save button, uses a dummy input argument. + """ + filepath, _ = QtWidgets.QFileDialog.getSaveFileName( + parent=self, + caption="Choose location to save the scenario file", + filter="Excel (*.xlsx *.xls);; CSV (*.csv)", + ) + print("Saving scenario dataframe to file: ", filepath) + scenarios = self._scenario_dataframe.columns.difference( + ["input", "output", "flow"] + ) + superstructure = ss.SUPERSTRUCTURE.tolist() + cols = superstructure + scenarios.tolist() + + savedf = pd.DataFrame(index=self._scenario_dataframe.index, columns=cols) + for table in self.tables: + indices = savedf.index.intersection(table.scenario_df.index) + savedf.loc[indices, superstructure] = table.scenario_df.loc[ + indices, superstructure + ] + savedf.loc[indices, scenarios] = self._scenario_dataframe.loc[ + indices, scenarios + ] + if filepath.endswith(".xlsx") or filepath.endswith(".xls"): + savedf.to_excel(filepath, index=False) + return + elif not filepath.endswith(".csv"): + filepath += ".csv" + savedf.to_csv(filepath, index=False, sep=";") + + def save_button(self, visible: bool): + self.save_scenario.setDisabled(not visible) + self.show() + self.updateGeometry() + + +class ScenarioImportWidget(QtWidgets.QWidget): + def __init__(self, index: int, parent=None): + super().__init__(parent) + self._parent = parent + self.index = index + self.scenario_name = QtWidgets.QLabel("", self) + self.load_btn = QtWidgets.QPushButton(icons.qicons.import_db, "Load") + self.load_btn.setToolTip("Load (new) data for this scenario table") + self.remove_btn = QtWidgets.QPushButton(icons.qicons.delete, "Delete") + self.remove_btn.setToolTip("Remove this scenario table") + self.view = widgets.ABTreeView(self) + self.model = widgets.ABItemModel(self) + self.view.setModel(self.model) + self.scenario_df = pd.DataFrame(columns=ss.SUPERSTRUCTURE) + + layout = QtWidgets.QVBoxLayout() + + row = QtWidgets.QHBoxLayout() + row.addWidget(self.scenario_name) + row.addWidget(self.load_btn) + row.addStretch(1) + row.addWidget(self.remove_btn) + + layout.addLayout(row) + layout.addWidget(self.view) + layout.addStretch(1) + self.setLayout(layout) + self.connect_signals() + + def connect_signals(self): + self.load_btn.clicked.connect(self.load_action) + parent = self.parent() + if parent and isinstance(parent, ScenarioSection): + self.remove_btn.clicked.connect(lambda: parent.remove_table(self.index)) + self.remove_btn.clicked.connect(parent.can_add_table) + + def load_action(self) -> None: + dialog = ExcelReadDialog(self) + if dialog.exec_() != ExcelReadDialog.DialogCode.Accepted: + return + + try: + path = dialog.path + idx = dialog.import_sheet.currentIndex() + file_type_suffix = dialog.path.suffix + separator = dialog.field_separator.currentData() + logger.debug("separator == '{}'".format(separator)) + QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) + logger.info("Loading Scenario file. This may take a while for large files") + # Try and read as a superstructure file + # Choose a different routine for reading the file dependent on file type + if file_type_suffix == ".feather": + df = ss.ABFeatherImporter.read_file(path) + elif file_type_suffix.startswith(".xls"): + df = ss.import_from_excel(path, idx) + else: + df = ss.ABCSVImporter.read_file(path, separator=separator) + # Read in the file as a scenario flow table if the file is arranged as one + if len(df.columns.intersection(ss.SUPERSTRUCTURE)) >= 12: + if df is None: + QtWidgets.QApplication.restoreOverrideCursor() + return + self.sync_superstructure(df) + # Read the file as a parameter scenario file if it is correspondingly arranged + elif len(df.columns.intersection({"Name", "Group"})) == 2: + # Try and read as parameter scenario file. + logger.info("Superstructure: Attempting to read as parameter scenario file.") + + if not df["Group"].dtype == object: + df["Group"] = df["Group"].astype(str) + + df = ss.parameters_to_sdf(df) + self.sync_superstructure(df) + + else: + # this is a wrong file type + msg = ( + "The Activity-Browser is attempting to import a scenario file.

During the attempted import" + " another file type was detected. Please check the file type of the attempted import, if it is" + " a scenario file make sure it contains a valid format.

" + "

A flow exchange scenario file requires the following headers:
" + + ss.edit_superstructure_for_string(sep=", ", fhighlight='"') + + "

" + "

A parameter scenario file requires the following:
" + + ss.edit_superstructure_for_string( + ["name", "group"], sep=", ", fhighlight='"' + ) + + "

" + ) + critical = ss.ABPopup.abCritical( + "Wrong file type", msg, QtWidgets.QPushButton("Cancel") + ) + QtWidgets.QApplication.restoreOverrideCursor() + critical.exec_() + return + except: + QtWidgets.QApplication.restoreOverrideCursor() + raise + + self.scenario_name.setText(path.name) + self.scenario_name.setToolTip(path.name) + self._parent.save_button(True) + + QtWidgets.QApplication.restoreOverrideCursor() + + def sync_superstructure(self, df: pd.DataFrame) -> None: + """synchronizes the contents of either a single, or multiple scenario files to create a single scenario + dataframe""" + QtWidgets.QApplication.restoreOverrideCursor() + df = self.scenario_db_check(df) + QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) + df = ss.SuperstructureManager.fill_empty_process_keys_in_exchanges(df) + ss.SuperstructureManager.verify_scenario_process_keys(df) + df = ss.SuperstructureManager.check_duplicates(df) + # If we've cancelled the import then we don't want to load the dataframe + if df.empty: + return + self.scenario_df = df + cols = ss.scenario_names_from_df(self.scenario_df) + self.model.setDataFrame(pd.DataFrame(cols, columns=["Scenarios"])) + self._parent.combined_dataframe() + + def scenario_db_check(self, df: pd.DataFrame) -> pd.DataFrame: + dbs = set(df.loc[:, "from database"]).union(set(df.loc[:, "to database"])) + unlinkable = dbs.difference(bd.databases) + db_lst = list(bd.databases) + relink = [] + for db in unlinkable: + relink.append((db, db_lst)) + # check for databases in the scenario dataframe that cannot be linked to + if unlinkable: + dialog = ScenarioDatabaseDialog.construct_dialog(self._parent, relink) + if dialog.exec_() == QtWidgets.QDialog.Accepted: + # TODO On update to bw2.5 this should be changed to use the bw2data.utils.get_node method + return ss.scenario_replace_databases(df, dialog.relink) + # generate the required dialog + return df + + @property + def dataframe(self) -> pd.DataFrame: + if self.scenario_df.empty: + logger.debug("No data in scenario table {}, skipping".format(self.index + 1)) + return self.scenario_df + + +class ExcelReadDialog(QtWidgets.QDialog): + SUFFIXES = { + ".xls", + ".xlsx", + ".bz2", + ".zip", + ".gz", + ".xz", + ".tar", + ".csv", + ".feather", + } + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Select file to read") + + self.path_layout = QtWidgets.QGridLayout() + self.path = None + self.path_line = QtWidgets.QLineEdit() + self.path_line.setReadOnly(True) + self.path_line.textChanged.connect(self.changed) + self.path_btn = QtWidgets.QPushButton("Browse") + self.path_btn.clicked.connect(self.browse) + self.path_layout.addWidget(QtWidgets.QLabel("Path to file*"), 0, 0, 1, 1) + self.path_layout.addWidget(self.path_line, 0, 1, 1, 2) + self.path_layout.addWidget(self.path_btn, 0, 3, 1, 1) + self.path = QtWidgets.QWidget() + self.path.setLayout(self.path_layout) + + self.excel_option = QtWidgets.QHBoxLayout() + self.import_sheet = QtWidgets.QComboBox() + self.import_sheet.addItems(["-----"]) + self.import_sheet.setEnabled(True) + self.excel_option.addWidget( + QtWidgets.QLabel("Excel sheet name") + ) # , 0, 0, 1, 1) + self.excel_option.addWidget(self.import_sheet) # , 0, 1, 2, 1) + self.excel_sheet = QtWidgets.QWidget() + self.excel_sheet.setLayout(self.excel_option) + self.excel_sheet.setVisible(False) + + self.csv_option = QtWidgets.QHBoxLayout() + self.field_separator = QtWidgets.QComboBox() + for l, s in {";": ";", ",": ",", "tab": "\t"}.items(): + self.field_separator.addItem(l, s) + self.field_separator.setEnabled(True) + self.csv_option.addWidget( + QtWidgets.QLabel("Separator for csv") + ) # , 0, 0, 1, 1) + self.csv_option.addWidget(self.field_separator) # , 0, 1, 2, 1) + self.csv_separator = QtWidgets.QWidget() + self.csv_separator.setLayout(self.csv_option) + self.csv_separator.setVisible(False) + + self.complete = False + + self.buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, + ) + self.buttons.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(self.complete) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + + layout = QtWidgets.QVBoxLayout() + grid = QtWidgets.QVBoxLayout() + grid.addWidget(self.path) + grid.addWidget(self.excel_sheet) + grid.addWidget(self.csv_separator) + + input_box = QtWidgets.QGroupBox(self) + input_box.setLayout(grid) + layout.addWidget(input_box) + layout.addWidget(self.buttons) + self.setLayout(layout) + + def browse(self) -> None: + path, _ = QtWidgets.QFileDialog.getOpenFileName( + parent=self, + caption="Select scenario template file", + filter="Excel (*.xlsx);; feather (*.feather);; CSV and Archived (*.csv *.zip *.tar *.bz2 *.gz *.xz);; All Files (*.*)", + selectedFilter="All Files (*.*)", + ) + if path: + self.path_line.setText(path) + + def update_combobox(self, file_path) -> None: + self.import_sheet.blockSignals(True) + self.import_sheet.clear() + names = ss.get_sheet_names(file_path) + self.import_sheet.addItems(names) + self.import_sheet.blockSignals(False) + + def changed(self) -> None: + """Determine if selected path is valid.""" + self.path = Path(self.path_line.text()) + self.complete = all( + [self.path.exists(), self.path.is_file(), self.path.suffix in self.SUFFIXES] + ) + if self.complete and self.path.suffix.startswith(".xls"): + self.update_combobox(self.path) + self.excel_sheet.setVisible(self.import_sheet.count() > 0) + self.csv_separator.setVisible(False) + elif self.complete and self.path.suffix in { + ".csv", + ".zip", + ".tar", + ".bz2", + ".gz", + ".xz", + }: + self.csv_separator.setVisible(True) + self.excel_sheet.setVisible(False) + else: + self.csv_separator.setVisible(False) + self.excel_sheet.setVisible(False) + self.buttons.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(self.complete) + + +class ScenarioDatabaseDialog(QtWidgets.QDialog): + """ + Displays the possible databases for relinking the exchanges for a given activity + """ + + def __init__(self, parent: QtWidgets.QWidget = None): + super().__init__(parent) + self.setWindowTitle("Linking scenario databases") + + self.label = QtWidgets.QLabel( + "The following database(s) in the scenario file cannot be found in your project.\n\n" + "Please indicate the corresponding database(s), or cancel the import if this is not" + " possible. (Warning: this process may take a few minutes for large scenario files)" + ) + + self.label_choices = [] + self.grid_box = QtWidgets.QGroupBox("DatabasesPane:") + self.grid = QtWidgets.QGridLayout() + self.grid_box.setLayout(self.grid) + + self.buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, + ) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.label) + layout.addWidget(self.grid_box) + layout.addWidget(self.buttons) + self.setLayout(layout) + + @property + def relink(self) -> dict: + """Returns a dictionary of str -> str key/values, showing which keys + should be linked to which values. + + Only returns key/value pairs if they differ. + """ + return { + label.text(): combo.currentText() + for label, combo in self.label_choices + if label.text() != combo.currentText() + } + + @classmethod + def construct_dialog(cls, parent: QtWidgets.QWidget = None, options: list = None) -> "ScenarioDatabaseDialog": + obj = cls(parent) + # Start at 1 because row 0 is taken up by the db_label + for i, item in enumerate(options): + label = QtWidgets.QLabel(item[0]) + combo = QtWidgets.QComboBox() + combo.addItems(item[1]) + combo.setCurrentIndex(0) + obj.label_choices.append((label, combo)) + obj.grid.addWidget(label, i, 0, 1, 2) + obj.grid.addWidget(combo, i, 2, 1, 2) + obj.updateGeometry() + return obj + diff --git a/activity_browser/app/pages/impact_category_details/__init__.py b/activity_browser/app/pages/impact_category_details/__init__.py new file mode 100644 index 000000000..edf27be8d --- /dev/null +++ b/activity_browser/app/pages/impact_category_details/__init__.py @@ -0,0 +1 @@ +from .impact_category_details import ImpactCategoryDetailsPage \ No newline at end of file diff --git a/activity_browser/app/pages/impact_category_details/impact_category_details.py b/activity_browser/app/pages/impact_category_details/impact_category_details.py new file mode 100644 index 000000000..e46fd9537 --- /dev/null +++ b/activity_browser/app/pages/impact_category_details/impact_category_details.py @@ -0,0 +1,307 @@ +from qtpy import QtWidgets, QtGui, QtCore +from qtpy.QtCore import Qt +from loguru import logger + +import bw2data as bd +import pandas as pd + +from activity_browser import app +from activity_browser.ui import widgets, icons, delegates, core +from activity_browser.bwutils.commontasks import is_node_biosphere + +from .impact_category_header import ImpactCategoryHeader + + +class ImpactCategoryDetailsPage(QtWidgets.QWidget): + def __init__(self, name: tuple, parent=None): + super().__init__(parent) + self.name = name + self.impact_category = bd.Method(name) + self.is_editable = False + + self.setObjectName(" | ".join(name)) + + self.header = ImpactCategoryHeader(self) + + self.view = CharacterizationFactorsView(self) + self.model = CharacterizationFactorsModel(page=self) + self.view.setModel(self.model) + + self.build_layout() + self.connect_signals() + self.sync() + + def connect_signals(self): + app.signals.method.renamed.connect(self.on_method_renamed) + app.signals.method.deleted.connect(self.on_method_deleted) + app.signals.meta.methods_changed.connect(self.sync) + + def on_method_renamed(self, old_name, new_name): + if self.name == old_name: + self.name = new_name + self.setObjectName(" | ".join(new_name)) + self.setWindowTitle(" | ".join(new_name)) + + def on_method_deleted(self, method): + if method.name == self.name: + self.deleteLater() + + def sync(self): + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + if self.name not in bd.methods: + self.deleteLater() + return + + self.impact_category = bd.Method(self.name) + df = self.build_df() + df.reset_index(drop=True, inplace=True) + self.model.set_dataframe(df) + self.header.sync() + + def build_layout(self): + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.header) + layout.addWidget(widgets.ABHLine(self)) + layout.addWidget(self.view) + self.setLayout(layout) + + def build_df(self): + df = pd.DataFrame(self.impact_category.load(), columns=["id", "data"]) + df["amount"] = df["data"].apply(lambda x: x if isinstance(x, (float, int)) else x.get("amount")) + df["uncertainty"] = df["data"].apply(self.uncertainty_from_cf) + + other = app.metadata.dataframe[["id", "name", "categories", "database", "unit"]] + + df = df.merge(other, left_on="id", right_on="id").rename(columns={"id": "_id", "data": "_cf"}) + df["_impact_category_name"] = [self.name for i in range(len(df))] + df["_editable"] = self.is_editable + + cols = ["name", "categories", "database", "amount", "unit", "uncertainty", "_id", "_impact_category_name", "_cf", "_editable"] + return df[cols] + + def uncertainty_from_cf(self, cf): + if isinstance(cf, dict): + uncertainty_keys = { + "uncertainty type", + "loc", + "scale", + "shape", + "minimum", + "maximum", + "negative", + } + return {k: v for k, v in cf.items() if k in uncertainty_keys} + return 0 + + +class CharacterizationFactorsView(widgets.ABTreeView): + defaultColumnDelegates = { + "amount": delegates.FloatDelegate, + "categories": delegates.ListDelegate, + "uncertainty": delegates.UncertaintyDelegate, + } + + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m: m.add(app.actions.CFRemove, m.impact_category_name, m.char_factors, + enable=bool(m.char_factors) and m.is_editable, + text="Remove characterization factor(s)"), + ] + + @property + def is_editable(self): + table_view: CharacterizationFactorsView = self.parent() + return table_view.page.is_editable + + @property + def impact_category_name(self): + table_view: CharacterizationFactorsView = self.parent() + return table_view.page.name + + @property + def char_factors(self): + table_view: CharacterizationFactorsView = self.parent() + table_model: CharacterizationFactorsModel = table_view.model() + + selected_indices = table_view.selectedIndexes() + ids = table_model.values_from_indices("_id", selected_indices) + cfs = table_model.values_from_indices("_cf", selected_indices) + return list(zip(ids, cfs)) + + def __init__(self, parent): + super().__init__(parent) + self.setAcceptDrops(True) + self.setSortingEnabled(True) + self.overlay = None + + @property + def page(self): + """Returns the ImpactCategoryDetailsPage associated with the view.""" + return self.parent() + + def dragEnterEvent(self, event): + """ + Handles the drag enter event. + + Args: + event: The drag enter event. + """ + if not self.parent().is_editable: + event.ignore() + return + + if event.mimeData().hasFormat("application/bw-nodekeylist"): + self.overlay = widgets.ABDropOverlay(self) + self.overlay.show() + event.accept() + else: + event.ignore() + + def dragMoveEvent(self, event): + """Handles the drag move event - required for proper drop indicator.""" + if not self.parent().is_editable: + event.ignore() + return + + if event.mimeData().hasFormat("application/bw-nodekeylist"): + event.accept() + else: + event.ignore() + + def dragLeaveEvent(self, event): + """ + Handles the drag leave event. + + Args: + event: The drag leave event. + """ + if not self.overlay is None: + # Reset the palette on drag leave + self.overlay.deleteLater() + self.overlay = None + + def dropEvent(self, event): + """ + Handles the drop event. + + Args: + event: The drop event. + """ + self.overlay.deleteLater() + self.overlay = None + + keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") + + # Filter to only biosphere flows + biosphere_keys = [key for key in keys if is_node_biosphere(key)] + + if biosphere_keys: + app.actions.CFNew.run(self.parent().name, biosphere_keys) + + +class CharacterizationFactorsModel(core.ABTreeModel): + """ + A model representing the characterization factors data. + """ + def __init__(self, page: ImpactCategoryDetailsPage): + super().__init__(parent=page, enable_sorting=True) + self.page = page + + def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: + """ + Sorts the model based on the given column and order. + + Args: + column (int): The column index to sort by. + order (Qt.SortOrder): The order to sort (ascending or descending). + """ + column_name = self.columns()[column] + if column_name == "uncertainty": + return + super().sort(column, order) + + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: + """ + Sets the data for the given index. + + Args: + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. + + Returns: + bool: True if the data was set successfully, False otherwise. + """ + if role != Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False + + if column_name == "amount": + app.actions.CFAmountModify.run(row["_impact_category_name"], row["_id"], value) + return True + + if column_name == "uncertainty": + app.actions.CFUncertaintyModify.run( + row["_impact_category_name"], [(row["_id"], row["_cf"])], uncertainty_dict=value + ) + return True + + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: + """ + Provides decoration data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide decoration data. + + Returns: + The decoration data for the index. + """ + column_name = self.column_name(index) + if column_name == "name": + return icons.qicons.biosphere + + return None + + def fontData(self, index: QtCore.QModelIndex) -> any: + """ + Provides font data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide font data. + + Returns: + QtGui.QFont: The font data for the index. + """ + column_name = self.column_name(index) + if column_name == "name": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font + + return None + + def indexEditable(self, index): + """ + Returns whether the index is editable. + + Args: + index (QtCore.QModelIndex): The index to check. + + Returns: + bool: True if the index is editable, False otherwise. + """ + column_name = self.column_name(index) + # Allow editing for amount and uncertainty if editable + if column_name in ["amount", "uncertainty"] and self.get(index, "_editable"): + return True + + return False + diff --git a/activity_browser/app/pages/impact_category_details/impact_category_header.py b/activity_browser/app/pages/impact_category_details/impact_category_header.py new file mode 100644 index 000000000..492728e7c --- /dev/null +++ b/activity_browser/app/pages/impact_category_details/impact_category_header.py @@ -0,0 +1,192 @@ +from qtpy import QtWidgets, QtCore +from loguru import logger + +from activity_browser import app +from activity_browser.ui import widgets + + +class ImpactCategoryHeader(QtWidgets.QWidget): + + def __init__(self, parent: QtWidgets.QWidget): + """ + Initializes the ImpactCategoryHeader widget with a stack layout + that switches between editable and view-only headers. + + Args: + parent (QtWidgets.QWidget): The parent widget. + """ + super().__init__(parent) + self.impact_category = parent.impact_category + + # Set size policy to only take needed vertical space + self.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Maximum + ) + + # Create stack layout to hold both header types + self.stack = QtWidgets.QStackedLayout() + self.stack.setContentsMargins(0, 0, 0, 0) + self.stack.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetMinimumSize) + + # Create both header widgets + self.view_only_header = ViewOnlyHeader(self) + self.editable_header = EditableHeader(self) + + # Add headers to stack + self.stack.addWidget(self.view_only_header) # Index 0 + self.stack.addWidget(self.editable_header) # Index 1 + + self.setLayout(self.stack) + + def sync(self): + """ + Synchronizes the widget with the current state of the impact category. + Switches between editable and view-only headers based on edit mode. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + self.impact_category = self.parent().impact_category + + # Update both headers with current data + self.view_only_header.sync() + self.editable_header.sync() + + # Switch to appropriate header based on edit mode + if self.parent().is_editable: + self.stack.setCurrentIndex(1) # Show editable header + else: + self.stack.setCurrentIndex(0) # Show view-only header + + def on_editable_changed(self): + """ + Called when the edit button is clicked. + Notifies the parent page to update the view accordingly. + """ + self.parent().is_editable = not self.parent().is_editable + self.parent().sync() + + +class ViewOnlyHeader(QtWidgets.QWidget): + """ + A read-only header widget that displays impact category information. + """ + + def __init__(self, parent: ImpactCategoryHeader): + """ + Initializes the view-only header. + + Args: + parent (ImpactCategoryHeader): The parent header widget. + """ + super().__init__(parent) + self.grid = QtWidgets.QGridLayout() + self.grid.setContentsMargins(0, 5, 0, 5) + self.grid.setSpacing(10) + self.grid.setColumnStretch(0, 0) # Column 0 doesn't stretch + self.grid.setColumnStretch(1, 1) # Column 1 takes remaining space + self.grid.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + self.setLayout(self.grid) + + # Create widgets + self.name_label = QtWidgets.QLabel(self) + self.unit_label = QtWidgets.QLabel(self) + self.edit_button = QtWidgets.QPushButton("Edit Impact Category", self) + self.edit_button.clicked.connect(parent.on_editable_changed) + + # Layout widgets + self.grid.addWidget(widgets.ABLabel.demiBold("Name:", self), 0, 0) + self.grid.addWidget(self.name_label, 0, 1) + self.grid.addWidget(self.edit_button, 0, 1, QtCore.Qt.AlignmentFlag.AlignRight) + self.grid.addWidget(widgets.ABLabel.demiBold("Unit:", self), 1, 0) + self.grid.addWidget(self.unit_label, 1, 1) + + def sync(self): + """ + Updates the displayed information from the current impact category. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + impact_category = self.parent().impact_category + self.name_label.setText(" | ".join(impact_category.name)) + self.unit_label.setText(impact_category.metadata.get("unit", "Undefined")) + + +class EditableHeader(QtWidgets.QWidget): + """ + An editable header widget that allows modifying impact category information. + """ + + def __init__(self, parent: ImpactCategoryHeader): + """ + Initializes the editable header. + + Args: + parent (ImpactCategoryHeader): The parent header widget. + """ + super().__init__(parent) + self.grid = QtWidgets.QGridLayout() + self.grid.setContentsMargins(0, 5, 0, 5) + self.grid.setSpacing(10) + self.grid.setColumnStretch(0, 0) # Column 0 doesn't stretch + self.grid.setColumnStretch(1, 1) # Column 1 takes remaining space + self.grid.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + self.setLayout(self.grid) + + # Create widgets + self.name_label = QtWidgets.QLabel(self) + self.name_label.linkActivated.connect(self._rename_method) + self.unit_edit = ImpactCategoryUnit(self) + self.done_button = QtWidgets.QPushButton("Done editing", self) + self.done_button.clicked.connect(parent.on_editable_changed) + + # Layout widgets + self.grid.addWidget(widgets.ABLabel.demiBold("Name:", self), 0, 0) + self.grid.addWidget(self.name_label, 0, 1) + self.grid.addWidget(self.done_button, 0, 1, QtCore.Qt.AlignmentFlag.AlignRight) + self.grid.addWidget(widgets.ABLabel.demiBold("Unit:", self), 1, 0) + self.grid.addWidget(self.unit_edit, 1, 1) + + def sync(self): + """ + Updates the displayed information from the current impact category. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + impact_category = self.parent().impact_category + self.name_label.setText(f"{' | '.join(impact_category.name)}") + self.unit_edit.setText(impact_category.metadata.get("unit", "Undefined")) + + def _rename_method(self): + """ + Triggers the method rename action. + """ + app.actions.MethodRename.run(self.parent().impact_category.name) + + +class ImpactCategoryUnit(QtWidgets.QLineEdit): + """ + A line edit widget for editing the impact category unit. + """ + + def __init__(self, parent: EditableHeader): + """ + Initializes the unit edit widget. + + Args: + parent (EditableHeader): The parent editable header widget. + """ + super().__init__(parent) + self.editingFinished.connect(self.change_unit) + + def change_unit(self): + """ + Updates the impact category unit when editing is finished. + """ + impact_category = self.parent().parent().impact_category + current_unit = impact_category.metadata.get("unit", "Undefined") + + if self.text() == current_unit: + return + + app.actions.MethodMetaModify.run(impact_category.name, "unit", self.text()) \ No newline at end of file diff --git a/activity_browser/app/pages/lca_results/LCA_results.py b/activity_browser/app/pages/lca_results/LCA_results.py new file mode 100644 index 000000000..b475f091c --- /dev/null +++ b/activity_browser/app/pages/lca_results/LCA_results.py @@ -0,0 +1,2277 @@ +from collections import namedtuple +from copy import deepcopy +from typing import List, Optional +from loguru import logger +from datetime import datetime + +import numpy as np +import pandas as pd +import bw2data as bd +from qtpy import QtCore, QtGui, QtWidgets + +from stats_arrays.errors import InvalidParamsError + +from activity_browser import app +from activity_browser.bwutils.commontasks import unit_of_method, get_LCIA_method_name_dict, format_activity_label +from activity_browser.bwutils.sensitivity_analysis import GlobalSensitivityAnalysis +from activity_browser.mod.bw2analyzer import ABContributionAnalysis +from activity_browser.ui import icons, widgets + +from .style import header, horizontal_line, vertical_line +from .tables import ContributionTable, InventoryTable, LCAResultsTable +from .plots import ContributionPlot, CorrelationPlot, LCAResultsBarChart, LCAResultsPlot, MonteCarloPlot +from .sankey_navigator import SankeyNavigatorWidget +from .tree_navigator import TreeNavigatorWidget + +ca = ABContributionAnalysis() + + +def get_header_layout(header_text: str) -> QtWidgets.QVBoxLayout: + vlayout = QtWidgets.QVBoxLayout() + vlayout.addWidget(header(header_text)) + vlayout.addWidget(horizontal_line()) + return vlayout + + +def get_header_layout_w_help(header_text: str, help_widget) -> QtWidgets.QVBoxLayout: + hlayout = QtWidgets.QHBoxLayout() + hlayout.addWidget(header(header_text)) + hlayout.addWidget(help_widget) + hlayout.setStretch(0, 1) + + vlayout = QtWidgets.QVBoxLayout() + vlayout.addLayout(hlayout) + vlayout.addWidget(horizontal_line()) + return vlayout + + +def get_unit(method: tuple, relative: bool = False) -> str: + """Get the unit for plot axis naming. + + Determine the unit based on whether a plot is shown: + - for a number of reference flows + - for a number of impact categories + and whether the axis are related to: + - relative or + - absolute numbers. + """ + if relative: + return "relative share" + if method: # for all reference flows + return unit_of_method(method) + return "units of each impact category" + + +# Special namedtuple for the LCAResults TabWidget. +Tabs = namedtuple( + "tabs", ("inventory", "results", "ef", "process", "sankey", "tree", "mc", "gsa") +) +Relativity = namedtuple("relativity", ("relative", "absolute")) +TotalMenu = namedtuple("total_menu", ("score", "range")) +ExportTable = namedtuple("export_table", ("label", "copy", "csv", "excel")) +ExportPlot = namedtuple("export_plot", ("label", "png", "svg")) +PlotTableCheck = namedtuple("plot_table_space", ("plot", "table", "invert")) +Combobox = namedtuple( + "combobox_menu", + ( + "func", + "func_label", + "method", + "method_label", + "agg", + "agg_label", + "scenario", + "scenario_label", + ), +) + + +class LCAResultsPage(QtWidgets.QTabWidget): + """Class for the main 'LCA Results' tab. + + Shows: + One sub-tab for each calculation setup + For each calculation setup-tab one array of relevant tabs. + """ + + update_scenario_box_index: QtCore.SignalInstance = QtCore.Signal(int) + + def __init__(self, cs_name, mlca, contributions, mc, parent=None): + super().__init__(parent) + self.setObjectName(f"{cs_name}-{datetime.now().strftime('%H:%M:%S')}") + self.setWindowTitle(f"{cs_name} [{datetime.now().strftime('%H:%M')}]") + + self.cs_name, self.mlca, self.contributions, self.mc = cs_name, mlca, contributions, mc + self.cs = bd.calculation_setups[self.cs_name] + self.has_scenarios: bool = hasattr(mlca, "scenario_names") + self.method_dict = get_LCIA_method_name_dict(self.mlca.methods) + self.single_func_unit = len(self.mlca.func_units) == 1 + self.single_method = len(self.mlca.methods) == 1 + + self.setMovable(True) + self.setVisible(False) + self.visible = False + + self.tabs = Tabs( + inventory=InventoryTab(self), + results=LCAResultsTab(self), + ef=ElementaryFlowContributionTab(self), + process=ProcessContributionsTab(self), + # ft=FirstTierContributionsTab(self.cs_name, parent=self), + sankey=SankeyNavigatorWidget(self.cs_name, parent=self), + tree=TreeNavigatorWidget(self.cs_name, parent=self), + mc=MonteCarloTab( + self + ), # mc=None if self.mc is None else MonteCarloTab(self), + gsa=GSATab(self), + ) + self.tab_names = Tabs( + inventory="Inventory", + results="LCA Results", + ef="EF Contributions", + process="Process Contributions", + # ft="FT Contributions", + sankey="Sankey", + tree="Tree", + mc="Monte Carlo", + gsa="Sensitivity Analysis", + ) + self.setup_tabs() + self.setCurrentWidget(self.tabs.results) + self.currentChanged.connect(self.generate_content_on_click) + + def setup_tabs(self): + """Have all the tabs pull in their required data and add them.""" + self._update_tabs() + for name, tab in zip(self.tab_names, self.tabs): + if tab: + self.addTab(tab, name) + if hasattr(tab, "configure_scenario"): + tab.configure_scenario() + + def _update_tabs(self): + """Update each sub-tab that can be updated.""" + for tab in self.tabs: + if tab and hasattr(tab, "update_tab"): + tab.update_tab() + self.tabs.sankey.update_calculation_setup(cs_name=self.cs_name) + + @QtCore.Slot(int, name="updateUnderlyingMatrices") + def update_scenario_data(self, index: int) -> None: + """Will calculate which scenario array to use and update all child tabs.""" + if index == self.mlca.current: + return + self.mlca.set_scenario(index) + self._update_tabs() + self.update_scenario_box_index.emit(index) + + @QtCore.Slot(int, name="generateSankeyOnClick") + def generate_content_on_click(self, index): + if index == self.indexOf(self.tabs.sankey): + 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() + + if index == self.indexOf(self.tabs.tree): + if not self.tabs.tree.has_rendered_once: + logger.info("Generating Tree Tab") + self.tabs.tree.new_tree() + + @QtCore.Slot(name="lciaScenarioExport") + def generate_lcia_scenario_csv(self): + """Create a dataframe of the impact category results for all reference flows, + impact categories and scenarios, then call the 'export to csv' + """ + df = self.mlca.lca_scores_to_dataframe() + filepath, _ = QtWidgets.QFileDialog.getSaveFileName( + parent=self, + caption="Choose location to save lca results", + filter="Comma Separated Values (*.csv);; All Files (*.*)", + ) + if filepath: + if not filepath.endswith(".csv"): + filepath += ".csv" + df.to_csv(filepath) + + @QtCore.Slot(name="lciaScenarioExport") + def generate_lcia_scenario_excel(self): + """Create a dataframe of the impact category results for all reference flows, + impact categories and scenarios, then call the 'export to excel' + """ + df = self.mlca.lca_scores_to_dataframe() + filepath, _ = QtWidgets.QFileDialog.getSaveFileName( + parent=self, + caption="Choose location to save lca results", + filter="Excel (*.xlsx);; All Files (*.*)", + ) + if filepath: + if not filepath.endswith(".xlsx"): + filepath += ".xlsx" + df.to_excel(filepath) + + +class NewAnalysisTab(QtWidgets.QWidget): + """Parent class around which all sub-tabs are built.""" + explain_text = "I explain what happens here" + + def __init__(self, parent=None): + super().__init__(parent) + + self.help_button: Optional[QtWidgets.QToolBar] = None + + self.parent = parent + self.has_scenarios = self.parent.has_scenarios + + # Important variables optionally used in subclasses + self.table: Optional[QtWidgets.QTableView] = None + self.plot: Optional[QtWidgets.QWidget] = None + self.plot_table: Optional[PlotTableCheck] = None + self.relativity: Optional[Relativity] = None + self.relative: Optional[bool] = None + self.total_menu: Optional[TotalMenu] = None + self.total_range: Optional[bool] = None + self.score_marker: Optional[bool] = None + self.export_plot: Optional[ExportPlot] = None + self.export_table: Optional[ExportTable] = None + + self.scenario_box = SmallComboBox() + self.pt_layout = QtWidgets.QVBoxLayout() + self.layout = QtWidgets.QVBoxLayout() + self.setLayout(self.layout) + + def build_main_space(self, invertable: bool = False) -> QtWidgets.QScrollArea: + """Assemble main space where plots, tables and relevant options are shown.""" + space = QtWidgets.QScrollArea() + widget = QtWidgets.QWidget() + self.pt_layout.setAlignment(QtCore.Qt.AlignTop) + widget.setLayout(self.pt_layout) + space.setWidget(widget) + space.setWidgetResizable(True) + + # Option switches + self.plot_table = PlotTableCheck(QtWidgets.QCheckBox("Plot"), QtWidgets.QCheckBox("Table"), None) + if invertable: + self.plot_table = PlotTableCheck( + QtWidgets.QCheckBox("Plot"), QtWidgets.QCheckBox("Table"), QtWidgets.QCheckBox("Invert") + ) + self.plot_table.invert.setChecked(False) + self.plot_table.invert.stateChanged.connect(self.invert_plot) + self.plot_table.plot.setChecked(True) + self.plot_table.table.setChecked(True) + self.plot_table.table.stateChanged.connect(self.space_check) + self.plot_table.plot.stateChanged.connect(self.space_check) + + # Assemble option row + row = QtWidgets.QHBoxLayout() + row.addWidget(self.plot_table.plot) + row.addWidget(self.plot_table.table) + row.addWidget(vertical_line()) + if invertable: + row.addWidget(self.plot_table.invert) + if self.relativity: + row.addWidget(self.relativity.relative) + row.addWidget(self.relativity.absolute) + self.relativity.relative.toggled.connect(self.relativity_check) + if self.total_menu: + row.addWidget(vertical_line()) + row.addWidget(self.total_menu.score) + row.addWidget(self.total_menu.range) + self.total_menu.range.toggled.connect(self.total_check) + if hasattr(self, "score_mrk_checkbox"): + row.addStretch() + row.addWidget(self.score_mrk_checkbox) + self.score_mrk_checkbox.toggled.connect(self.score_mrk_check) + if not hasattr(self, "score_mrk_checkbox"): + row.addStretch() + + # Assemble Table and Plot area + if self.table and self.plot: + self.pt_layout.addLayout(row) + if self.plot: + self.pt_layout.addWidget(self.plot, 1) + if self.table: + self.pt_layout.addWidget(self.table) + self.pt_layout.addStretch() + return space + + @QtCore.Slot(name="invertPlot") + def invert_plot(self): + self.plot_inversion = self.plot_table.invert.isChecked() + self.space_check() + self.update_plot() + + @QtCore.Slot(name="checkboxChanges") + def space_check(self): + """Show graph and/or table, whichever is selected. + + Can also hide both, if you want to do that. + """ + self.table.setVisible(self.plot_table.table.isChecked()) + self.plot.setVisible(self.plot_table.plot.isChecked()) + + @QtCore.Slot(bool, name="isRelativeToggled") + def relativity_check(self, checked: bool): + """Check if the relative or absolute option is selected.""" + self.relative = checked + self.update_tab() + + @QtCore.Slot(bool, name="isTotalToggled") + def total_check(self, checked: bool): + """Check if the relative or absolute option is selected.""" + self.total_range = checked + self.update_tab() + + @QtCore.Slot(bool, name="isScoreMarkerToggled") + def score_mrk_check(self, checked: bool): + self.score_marker = checked + + self.update_tab() + + def get_scenario_labels(self) -> List[str]: + """Get scenario labels if scenarios are used.""" + return self.parent.mlca.scenario_names if self.has_scenarios else [] + + def configure_scenario(self): + """Determine if scenario Qt widgets are visible or not and retrieve + scenario labels for the selection drop-down box. + """ + if self.scenario_box: + self.scenario_box.setVisible(self.has_scenarios) + self.update_combobox(self.scenario_box, self.get_scenario_labels()) + + @staticmethod + @QtCore.Slot(int, name="setBoxIndex") + def set_combobox_index(box: QtWidgets.QComboBox, index: int) -> None: + """Update the index on the given QComboBox without sending a signal.""" + box.blockSignals(True) + box.setCurrentIndex(index) + box.blockSignals(False) + + @staticmethod + def update_combobox(box: QtWidgets.QComboBox, labels: List[str]) -> None: + """Update the combobox menu.""" + box.blockSignals(True) + box.clear() + box.insertItems(0, labels) + box.blockSignals(False) + + def update_tab(self): + """Update the plot and table if they are present.""" + if self.plot and self.plot.isVisible: + self.update_plot() + if self.table and self.table.isVisible: + self.update_table() + if self.plot and self.plot.isVisible and self.table and self.table.isVisible: + self.space_check() + + def update_table(self, *args, **kwargs): + """Update the table.""" + self.table.model.sync(*args, **kwargs) + + def update_plot(self, *args, **kwargs): + """Update the plot.""" + self.plot.plot(*args, **kwargs) + self.export_plot.png.clicked.connect(self.plot.to_png) + self.export_plot.svg.clicked.connect(self.plot.to_svg) + + def build_export( + self, has_table: bool = True, has_plot: bool = True + ) -> QtWidgets.QHBoxLayout: + """Construct a custom export button layout. + + Produces layout with buttons for export of relevant sections (plot, table). + Options for figure are: + .png (image format useful for computer generated graphics) + .svg (scalable vector graphic, image is not pixels but data on where lines are, + useful in reports) + Options for Table are: + copy (copies the table to clipboard) + .csv (a comma separated values file of the table, useful for data storage) + Excel (an excel file, useful for exchanging with people and making visualizations) + """ + export_menu = QtWidgets.QHBoxLayout() + + # Export Plot + if has_plot: + plot_layout = QtWidgets.QHBoxLayout() + self.export_plot = ExportPlot( + QtWidgets.QLabel("Export plot:"), + QtWidgets.QPushButton(".png"), + QtWidgets.QPushButton(".svg"), + ) + self.export_plot.png.clicked.connect(self.plot.to_png) + self.export_plot.svg.clicked.connect(self.plot.to_svg) + for obj in self.export_plot: + plot_layout.addWidget(obj) + export_menu.addLayout(plot_layout) + + # Add seperator if both table and plot exist + if has_table and has_plot: + export_menu.addWidget(vertical_line()) + + # Export Table + if has_table: + table_layout = QtWidgets.QHBoxLayout() + self.export_table = ExportTable( + QtWidgets.QLabel("Export table:"), + QtWidgets.QPushButton("Copy"), + QtWidgets.QPushButton(".csv"), + QtWidgets.QPushButton("Excel"), + ) + self.export_table.copy.clicked.connect(self.table.to_clipboard) + self.export_table.csv.clicked.connect(self.table.to_csv) + self.export_table.excel.clicked.connect(self.table.to_excel) + for obj in self.export_table: + table_layout.addWidget(obj) + export_menu.addLayout(table_layout) + + export_menu.addStretch() + return export_menu + + def explanation(self): + """Builds and shows a message box containing whatever text is set + on self.explain_text + """ + return QtWidgets.QMessageBox.question( + self, "Explanation", self.explain_text, QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.Ok + ) + + +class InventoryTab(NewAnalysisTab): + """Class for the 'Inventory' sub-tab. + + This tab allows for investigation of the inventories of the calculation. + + Shows: + Option to choose between 'Biosphere flows' and 'Technosphere flows' + Inventory table for either 'Biosphere flows' or 'Technosphere flows' + Export options + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.df_biosphere = None + self.df_technosphere = None + + self.layout.addLayout(get_header_layout("Inventory")) + self.bio_tech_button_group = QtWidgets.QButtonGroup() + self.bio_categorisation_factor_group = QtWidgets.QComboBox() + # buttons + button_layout = QtWidgets.QHBoxLayout() + self.radio_button_biosphere = QtWidgets.QRadioButton("Biosphere flows") + self.radio_button_biosphere.setChecked(True) + + self.radio_button_technosphere = QtWidgets.QRadioButton("Technosphere flows") + self.remove_zeros_checkbox = QtWidgets.QCheckBox("Remove '0' values") + self.remove_zero_state = False + + self.categorisation_factor_filters = [ + "No filtering with categorisation factors", + "Flows without categorisation factors", + "Flows with categorisation factors", + ] + self.categorisation_factor_state = None + self.old_categorisation_factor_state = self.categorisation_factor_state + + self.last_remove_zero_state = self.remove_zero_state + self.remove_zeros_checkbox.setChecked(self.remove_zero_state) + self.remove_zeros_checkbox.setToolTip( + "Choose whether to show '0' values or not.\n" + "When selected, '0' values are not shown.\n" + "Rows are only removed when all reference flows are '0'." + ) + self.scenario_label = QtWidgets.QLabel("Scenario:") + + # Group the radio buttons into the appropriate groups for the window + self.update_combobox( + self.bio_categorisation_factor_group, self.categorisation_factor_filters + ) + self.bio_categorisation_factor_group.setMaximumWidth(300) + self.bio_categorisation_factor_group.setSizeAdjustPolicy( + QtWidgets.QComboBox.AdjustToContentsOnFirstShow + ) + + # Setup the Qt environment for the buttons, including the arrangement + self.categorisation_filter_layout = QtWidgets.QVBoxLayout() + self.categorisation_filter_layout.addWidget(QtWidgets.QLabel("Filter flows:")) + self.categorisation_filter_layout.addWidget( + self.bio_categorisation_factor_group + ) + self.categorisation_filter_box = QtWidgets.QWidget() + self.categorisation_filter_box.setLayout(self.categorisation_filter_layout) + self.categorisation_filter_box.setVisible(True) + self.categorisation_filter_with_flows = None + + button_layout.addWidget(self.radio_button_biosphere) + button_layout.addWidget(self.radio_button_technosphere) + button_layout.addWidget(self.scenario_label) + button_layout.addWidget(self.scenario_box) + button_layout.addStretch(1) + button_layout.addWidget(self.remove_zeros_checkbox) + self.layout.addLayout(button_layout) + self.layout.addWidget(self.categorisation_filter_box) + # table + self.table = InventoryTable(self.parent) + self.table.table_name = "Inventory_" + self.parent.cs_name + self.layout.addWidget(self.table) + + self.layout.addLayout(self.build_export(has_plot=False, has_table=True)) + self.connect_signals() + + def connect_signals(self): + self.radio_button_biosphere.toggled.connect(self.button_clicked) + self.remove_zeros_checkbox.toggled.connect(self.remove_zeros_checked) + self.bio_tech_button_group.buttonClicked.connect( + self.toggle_categorisation_factor_filter_buttons + ) + self.bio_categorisation_factor_group.activated.connect( + self.add_categorisation_factor_filter + ) + if self.has_scenarios: + self.scenario_box.currentIndexChanged.connect( + self.parent.update_scenario_data + ) + self.parent.update_scenario_box_index.connect( + lambda index: self.set_combobox_index(self.scenario_box, index) + ) + + @QtCore.Slot(QtWidgets.QRadioButton, name="addCategorisationFactorFilter") + def add_categorisation_factor_filter(self, index: int): + if ( + self.bio_categorisation_factor_group.currentText() + == "Flows without categorisation factors" + ): + self.categorisation_filter_with_flows = False + self.categorisation_factor_state = False + elif ( + self.bio_categorisation_factor_group.currentText() + == "Flows with categorisation factors" + ): + self.categorisation_filter_with_flows = True + self.categorisation_factor_state = True + else: + self.categorisation_filter_with_flows = None + self.categorisation_factor_state = None + self.update_table() + self.old_categorisation_factor_state = self.categorisation_factor_state + + @QtCore.Slot(QtWidgets.QRadioButton, name="toggleCategorisationFactorFilterButtons") + def toggle_categorisation_factor_filter_buttons(self, bttn: QtWidgets.QRadioButton): + if bttn.text() == "Biosphere flows": + self.categorisation_filter_box.setVisible(True) + else: + self.categorisation_filter_box.setVisible(False) + self.categorisation_factor_state = None + + @QtCore.Slot(bool, name="isRemoveZerosToggled") + def remove_zeros_checked(self, toggled: bool): + """Update table according to remove-zero selected.""" + self.remove_zero_state = toggled + self.update_table() + self.last_remove_zero_state = self.remove_zero_state + + @QtCore.Slot(bool, name="isBiosphereToggled") + def button_clicked(self, toggled: bool): + """Update table according to radiobutton selected.""" + ext = "_Inventory" if toggled else "_Inventory_technosphere" + self.table.table_name = "{}{}".format(self.parent.cs_name, ext) + self.update_table() + + def configure_scenario(self): + """Allow scenarios options to be visible when used.""" + super().configure_scenario() + self.scenario_label.setVisible(self.has_scenarios) + + def update_tab(self): + """Update the tab.""" + self.clear_tables() + super().update_tab() + + def elementary_flows_contributing_to_IA_methods( + self, contributary: bool = True, bios: pd.DataFrame = None + ) -> pd.DataFrame: + """Returns a biosphere dataframe filtered for the presence in the impact assessment methods + Requires a boolean argument for whether those flows included in the impact assessment method + should be returned (True), or not (False) + """ + incl_flows = { + self.parent.contributions.inventory_data["biosphere"][1][k] + for mthd in self.parent.mlca.method_matrices + for k in mthd.indices + } + data = bios if bios is not None else self.df_biosphere + if contributary: + flows = incl_flows + else: + flows = ( + set(self.parent.contributions.inventory_data["biosphere"][1].values()) + ).difference(incl_flows) + return data.loc[data["id"].isin(flows)] + + def update_table(self): + """Update the table.""" + inventory = ( + "biosphere" if self.radio_button_biosphere.isChecked() else "technosphere" + ) + self.table.showing = inventory + # We handle both 'df_biosphere' and 'df_technosphere' variables here. + attr_name = "df_{}".format(inventory) + if ( + getattr(self, attr_name) is None + or self.remove_zero_state != self.last_remove_zero_state + or self.old_categorisation_factor_state != self.categorisation_factor_state + ): + setattr( + self, + attr_name, + self.parent.contributions.inventory_df(inventory_type=inventory), + ) + + # filter the biosphere flows for the relevance to the CFs + if ( + self.categorisation_filter_with_flows is not None + and inventory == "biosphere" + ): + self.df_biosphere = self.elementary_flows_contributing_to_IA_methods( + self.categorisation_filter_with_flows, self.df_biosphere + ) + + # filter the flows to remove those that have relevant exchanges + def filter_zeroes(df): + filter_on = [x for x in df.columns.tolist() if "|" in x] + return df[df[filter_on].sum(axis=1) != 0].reset_index(drop=True) + + if self.remove_zero_state and getattr(self, "df_biosphere") is not None: + self.df_biosphere = filter_zeroes(self.df_biosphere) + if self.remove_zero_state and getattr(self, "df_technosphere") is not None: + self.df_technosphere = filter_zeroes(self.df_technosphere) + + self._update_table(getattr(self, attr_name)) + + def clear_tables(self) -> None: + """Set the biosphere and technosphere to None.""" + self.df_biosphere, self.df_technosphere = None, None + + def _update_table(self, table: pd.DataFrame, drop: tuple = ("code", "id")): + """Update the table.""" + self.table.model.sync((table.drop(list(drop), axis=1)).reset_index(drop=True)) + + +class LCAResultsTab(NewAnalysisTab): + """Class for the 'LCA Results' sub-tab. + + This tab allows the user to get a basic overview of the results of the calculation setup. + + Shows: + 'Overview' and 'by impact category' options for different plots/graphs + Plots/graphs + Export buttons + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.parent = parent + self.lca_scores_widget = LCAScoresTab(parent) + self.lca_overview_widget = LCIAResultsTab(parent) + + self.layout.setAlignment(QtCore.Qt.AlignTop) + self.layout.addLayout(get_header_layout("LCA Results")) + + # buttons + button_layout = QtWidgets.QHBoxLayout() + self.button_group = QtWidgets.QButtonGroup() + self.button_overview = QtWidgets.QRadioButton("Overview") + self.button_overview.setToolTip( + "Show a matrix of all reference flows and all impact categories" + ) + button_layout.addWidget(self.button_overview) + self.button_by_method = QtWidgets.QRadioButton("by impact category") + self.button_by_method.setToolTip( + "Show the impacts of each reference flow for the selected impact categories" + ) + self.button_by_method.setChecked(True) + self.scenario_label = QtWidgets.QLabel("Scenario:") + self.button_group.addButton(self.button_overview, 0) + self.button_group.addButton(self.button_by_method, 1) + button_layout.addWidget(self.button_by_method) + button_layout.addWidget(self.scenario_label) + button_layout.addWidget(self.scenario_box) + button_layout.addStretch(1) + self.layout.addLayout(button_layout) + + self.layout.addWidget(self.lca_scores_widget) + self.layout.addWidget(self.lca_overview_widget) + + self.button_clicked(False) + self.connect_signals() + + def connect_signals(self): + self.button_overview.toggled.connect(self.button_clicked) + if self.has_scenarios: + self.scenario_box.currentIndexChanged.connect( + self.parent.update_scenario_data + ) + self.parent.update_scenario_box_index.connect( + lambda index: self.set_combobox_index(self.scenario_box, index) + ) + self.button_by_method.toggled.connect( + lambda on_lcia: self.scenario_box.setHidden(on_lcia) + ) + self.button_by_method.toggled.connect( + lambda on_lcia: self.scenario_label.setHidden(on_lcia) + ) + + @QtCore.Slot(bool, name="overviewToggled") + def button_clicked(self, is_overview: bool): + self.lca_overview_widget.setVisible(is_overview) + self.lca_scores_widget.setHidden(is_overview) + + def configure_scenario(self): + """Allow scenarios options to be visible when used.""" + super().configure_scenario() + self.scenario_box.setHidden(self.button_by_method.isChecked()) + self.scenario_label.setHidden(self.button_by_method.isChecked()) + + def update_tab(self): + """Update the tab.""" + self.lca_scores_widget.update_tab() + self.lca_overview_widget.update_tab() + + +class LCAScoresTab(NewAnalysisTab): + """Class for when 'by impact category' is chosen in the 'LCA Results' sub-tab.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.parent = parent + + self.combobox_menu = QtWidgets.QHBoxLayout() + self.combobox_label = QtWidgets.QLabel("Choose impact category:") + self.combobox = QtWidgets.QComboBox() + self.combobox.scroll = False + self.combobox_menu.addWidget(self.combobox_label) + self.combobox_menu.addWidget(self.combobox, 1) + self.combobox_menu.addStretch(1) + self.layout.addLayout(self.combobox_menu) + + self.plot = LCAResultsBarChart(self.parent) + self.plot.plot_name = "LCA scores_" + self.parent.cs_name + self.layout.addWidget(self.plot) + + self.layout.addLayout(self.build_export(has_plot=True, has_table=False)) + + self.connect_signals() + + def connect_signals(self): + self.combobox.currentIndexChanged.connect(self.update_plot) + + def build_export( + self, has_table: bool = True, has_plot: bool = True + ) -> QtWidgets.QHBoxLayout: + """Add 3d excel export if scenario-type LCA is performed.""" + layout = super().build_export(has_table, has_plot) + if self.has_scenarios: + # Remove the last QSpacerItem from the layout, + stretch = layout.takeAt(layout.count() - 1) + # Then add the additional label and export btn, plus new stretch. + exp_layout = QtWidgets.QHBoxLayout() + exp_layout.addWidget(QtWidgets.QLabel("Export all data")) + + csv_btn = QtWidgets.QPushButton(".csv") + csv_btn.setToolTip( + "Include all reference flows, impact categories and scenarios" + ) + if self.parent: + csv_btn.clicked.connect(self.parent.generate_lcia_scenario_csv) + + excel_btn = QtWidgets.QPushButton("Excel") + excel_btn.setToolTip( + "Include all reference flows, impact categories and scenarios" + ) + if self.parent: + excel_btn.clicked.connect(self.parent.generate_lcia_scenario_excel) + + exp_layout.addWidget(csv_btn) + exp_layout.addWidget(excel_btn) + layout.addWidget(vertical_line()) + layout.addLayout(exp_layout) + layout.addSpacerItem(stretch) + return layout + + def update_tab(self): + """Update the tab.""" + self.update_combobox(self.combobox, [str(m) for m in self.parent.mlca.methods]) + super().update_tab() + + @QtCore.Slot(int, name="updatePlotWithIndex") + def update_plot(self, method_index: int = 0): + """Update the plot.""" + method = self.parent.mlca.methods[method_index] + df = self.parent.mlca.get_results_for_method(method_index) + labels = [ + format_activity_label(next(iter(fu.keys())), style="pnld") + for fu in self.parent.mlca.func_units + ] + idx = self.layout.indexOf(self.plot) + self.plot.figure.clf() + self.plot.setVisible(False) + self.plot.deleteLater() + self.plot = LCAResultsBarChart(self.parent) + self.layout.insertWidget(idx, self.plot) + super().update_plot(df, method=method, labels=labels) + self.updateGeometry() + self.plot.plot_name = "_".join([self.parent.cs_name, "LCA scores", str(method)]) + + +class LCIAResultsTab(NewAnalysisTab): + """Class for when 'Overview' is chosen in the 'LCA Results' sub-tab.""" + + def __init__(self, parent, **kwargs): + super(LCIAResultsTab, self).__init__(parent, **kwargs) + self.parent = parent + self.df = None + self.plot_inversion = False + + # if not self.parent.single_func_unit: + self.plot = LCAResultsPlot(self.parent) + self.plot.plot_name = self.parent.cs_name + "_LCIA results" + self.table = LCAResultsTable(self.parent) + self.table.table_name = self.parent.cs_name + "_LCIA results" + self.relative = False + + self.layout.addWidget(self.build_main_space(True)) + self.layout.addLayout(self.build_export(True, True)) + + def build_export( + self, has_table: bool = True, has_plot: bool = True + ) -> QtWidgets.QHBoxLayout: + """Add 3d excel export if scenario-type LCA is performed.""" + layout = super().build_export(has_table, has_plot) + if self.has_scenarios: + # Remove the last QSpacerItem from the layout, + stretch = layout.takeAt(layout.count() - 1) + # Then add the additional label and export btn, plus new stretch. + exp_layout = QtWidgets.QHBoxLayout() + exp_layout.addWidget(QtWidgets.QLabel("Export all data")) + + csv_btn = QtWidgets.QPushButton(".csv") + csv_btn.setToolTip( + "Include all reference flows, impact categories and scenarios" + ) + if self.parent: + csv_btn.clicked.connect(self.parent.generate_lcia_scenario_csv) + + excel_btn = QtWidgets.QPushButton("Excel") + excel_btn.setToolTip( + "Include all reference flows, impact categories and scenarios" + ) + if self.parent: + excel_btn.clicked.connect(self.parent.generate_lcia_scenario_excel) + + exp_layout.addWidget(csv_btn) + exp_layout.addWidget(excel_btn) + layout.addWidget(vertical_line()) + layout.addLayout(exp_layout) + layout.addSpacerItem(stretch) + return layout + + def update_tab(self): + self.df = self.parent.contributions.lca_scores_df(normalized=self.relative) + super().update_tab() + + def update_plot(self): + """Update the plot.""" + idx = self.pt_layout.indexOf(self.plot) + self.plot.figure.clf() + self.plot.setVisible(False) + self.plot.deleteLater() + self.plot = LCAResultsPlot(self.parent) + self.pt_layout.insertWidget(idx, self.plot) + super().update_plot(self.df, invert_plot=self.plot_inversion) + if self.pt_layout.parentWidget(): + self.pt_layout.parentWidget().updateGeometry() + + def update_table(self): + super().update_table(self.df) + +class SmallComboBox(QtWidgets.QComboBox): + """A small combo box that does not expand to fill the available space.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + self.setMinimumWidth(100) + self.setMaximumWidth(200) + self.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContentsOnFirstShow) + + +class ContributionTab(NewAnalysisTab): + """Parent class for any 'XXX Contributions' sub-tab.""" + + def __init__(self, parent, **kwargs): + super().__init__(parent) + self.cutoff_menu = widgets.CutoffMenu(self, cutoff_value=0.05) + self.combobox_menu = Combobox( + func=QtWidgets.QComboBox(self), + func_label=QtWidgets.QLabel("Reference Flow:"), + method=SmallComboBox(self), + method_label=QtWidgets.QLabel("Impact Category:"), + agg=SmallComboBox(self), + agg_label=QtWidgets.QLabel("Aggregate by:"), + scenario=self.scenario_box, + scenario_label=QtWidgets.QLabel("Scenario:"), + ) + self.switch_label = QtWidgets.QLabel("Compare:") + self.switches = widgets.SwitchComboBox(self) + + self.relativity = Relativity( + QtWidgets.QRadioButton("Relative"), + QtWidgets.QRadioButton("Absolute"), + ) + self.relativity.relative.setChecked(True) + self.relative = True + self.relativity.relative.setToolTip( + "Show relative values (compare fraction of each contribution)" + ) + self.relativity.absolute.setToolTip( + "Show absolute values (compare magnitudes of each contribution)" + ) + self.relativity_group = QtWidgets.QButtonGroup(self) + self.relativity_group.addButton(self.relativity.relative) + self.relativity_group.addButton(self.relativity.absolute) + + self.total_menu = TotalMenu( + QtWidgets.QRadioButton("Score"), + QtWidgets.QRadioButton("Range"), + ) + self.total_menu.score.setChecked(True) + self.total_range = False + self.total_menu.score.setToolTip( + "Show the contributions relative to the total impact score.\n" + "e.g. total negative results is -2 and total positive results is 10, then score is 8 (-2 + 10)" + ) + self.total_menu.range.setToolTip( + "Show the contribution relative to the total range of results.\n" + "e.g. total negative results is -2 and total positive results is 10, then range is 12 (-2 * -1 + 10)" + ) + self.total_group = QtWidgets.QButtonGroup(self) + self.total_group.addButton(self.total_menu.score) + self.total_group.addButton(self.total_menu.range) + + self.score_marker = False + self.score_mrk_checkbox = QtWidgets.QCheckBox("Score Marker") + self.score_mrk_checkbox.setToolTip( + "Shows the score marker. When there are both positive and negative results,\n" + "this shows a marker where the total score is." + ) + self.score_mrk_checkbox.setChecked(self.score_marker) + + self.df = None + self.plot = ContributionPlot(self) + self.table = ContributionTable(self) + self.contribution_fn = None + self.has_method, self.has_func = False, False + self.unit = None + + self.has_been_opened = False + + # set-up the help button + self.explain_text = """ +

There are three ways of doing Contribtion Analysis in Activity Browser: +

- Elementary Flow (EF) Contributions

+

- Process Contributions

+

- First Tier (FT) Contributions

+ + Detailed information on the different approaches provided in this wiki page about the different approaches. + +

You can manipulate the results in many ways with Activity Browser, read more on this wiki page + about manipulating results. + """ + + self.help_button = QtWidgets.QToolBar(self) + self.help_button.addAction( + icons.qicons.question, "Left click for help on Contribution Analysis Functions", self.explanation + ) + + def set_filename(self, optional_fields: dict = None): + """Given a dictionary of fields, put together a usable filename for the plot and table.""" + optional = optional_fields or {} + fields = ( + self.parent.cs_name, + self.contribution_fn, + optional.get("method"), + optional.get("functional_unit"), + self.unit, + ) + + filename = "_".join((str(x) for x in fields if x is not None)) + self.plot.plot_name, self.table.table_name = filename, filename + + def build_combobox( + self, has_method: bool = True, has_func: bool = False + ) -> QtWidgets.QHBoxLayout: + """Construct a horizontal layout for picking and choosing what data to show and how.""" + menu = QtWidgets.QHBoxLayout() + # Populate the drop-down boxes with their relevant values. + self.combobox_menu.func.addItems( + list(self.parent.mlca.func_unit_translation_dict.keys()) + ) + self.combobox_menu.method.addItems(list(self.parent.method_dict.keys())) + + menu.addWidget(self.switch_label) + menu.addWidget(self.switches) + menu.addWidget(vertical_line()) + menu.addWidget(self.combobox_menu.scenario_label) + menu.addWidget(self.combobox_menu.scenario) + menu.addWidget(self.combobox_menu.method_label) + menu.addWidget(self.combobox_menu.method) + menu.addWidget(self.combobox_menu.func_label) + menu.addWidget(self.combobox_menu.func) + menu.addWidget(self.combobox_menu.agg_label) + menu.addWidget(self.combobox_menu.agg) + menu.addStretch() + + self.has_method = has_method + self.has_func = has_func + return menu + + def configure_scenario(self): + """Supplement the superclass method because there are more things to hide in these tabs.""" + super().configure_scenario() + visible = self.has_scenarios + self.combobox_menu.scenario_label.setVisible(visible) + + @QtCore.Slot(int, name="changeComparisonView") + def toggle_comparisons(self, index: int): + self.toggle_func(index == self.switches.indexes.func) + self.toggle_method(index == self.switches.indexes.method) + self.toggle_scenario(index == self.switches.indexes.scenario) + self.update_tab() + + @QtCore.Slot(bool, name="hideScenarioCombo") + def toggle_scenario(self, active: bool): + """Allow scenarios options to be visible when used.""" + if self.has_scenarios: + self.combobox_menu.scenario.setHidden(active) + self.combobox_menu.scenario_label.setHidden(active) + + @QtCore.Slot(bool, name="hideFuCombo") + def toggle_func(self, active: bool): + self.combobox_menu.func.setHidden(active) + self.combobox_menu.func_label.setHidden(active) + + @QtCore.Slot(bool, name="hideMethodCombo") + def toggle_method(self, active: bool): + self.combobox_menu.method.setHidden(active) + self.combobox_menu.method_label.setHidden(active) + + @QtCore.Slot(name="comboboxTriggerUpdate") + def set_combobox_changes(self): + """Update fields based on user-made changes in combobox. + + Any trigger linked to this slot will cause the values in the + combobox objects to be read out (which comparison, drop-down indexes, + etc.) and fed into update calls. + """ + # gather the combobox values + method = self.parent.method_dict[self.combobox_menu.method.currentText()] + functional_unit = self.combobox_menu.func.currentText() + scenario = max(self.combobox_menu.scenario.currentIndex(), 0) # set scenario 0 if not initiated yet + aggregator = self.combobox_menu.agg.currentText() + + # set aggregator to None if unwanted + if aggregator == "none": + aggregator = None + + # initiate dict with the field we want to compare + compare_fields = {"aggregator": aggregator} + + # Determine which comparison is active and update the comparison. + if self.switches.currentIndex() == self.switches.indexes.func: + compare_fields.update({"method": method, "scenario": scenario}) + elif self.switches.currentIndex() == self.switches.indexes.method: + compare_fields.update( + {"functional_unit": functional_unit, "scenario": scenario} + ) + elif self.switches.currentIndex() == self.switches.indexes.scenario: + compare_fields.update( + { + "method": method, + "functional_unit": functional_unit, + } + ) + + # Determine the unit for the figure, update the filenames and the + # underlying dataframe. + self.unit = get_unit(compare_fields.get("method"), self.relative) + self.set_filename(compare_fields) + self.df = self.update_dataframe(**compare_fields) + + def connect_signals(self): + """Override the inherited method to perform the same thing plus aggregation.""" + self.cutoff_menu.slider_change.connect(self.update_tab) + self.switches.currentIndexChanged.connect(self.toggle_comparisons) + self.combobox_menu.method.currentIndexChanged.connect(self.update_tab) + self.combobox_menu.func.currentIndexChanged.connect(self.update_tab) + self.combobox_menu.agg.currentIndexChanged.connect(self.update_tab) + self.combobox_menu.scenario.currentIndexChanged.connect(self.update_tab) + + def update_tab(self): + """Update the tab.""" + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + self.set_combobox_changes() + + super().update_tab() + QtWidgets.QApplication.restoreOverrideCursor() + + def update_dataframe(self, *args, **kwargs): + """Update the underlying dataframe. + + Implement in subclass.""" + raise NotImplementedError + + def update_table(self): + super().update_table(self.df, unit=self.unit) + + def update_plot(self): + """Update the plot.""" + idx = self.pt_layout.indexOf(self.plot) + self.plot.figure.clf() + # name is already altered by set_filename before update_plot occurs. + name = self.plot.plot_name + self.plot.setVisible(False) + self.plot.deleteLater() + self.plot = ContributionPlot(self) + self.pt_layout.insertWidget(idx, self.plot) + super().update_plot(self.df, unit=self.unit) + self.plot.plot_name = name + if self.pt_layout.parentWidget(): + self.pt_layout.parentWidget().updateGeometry() + + +class ElementaryFlowContributionTab(ContributionTab): + """Class for the 'Elementary flow Contributions' sub-tab. + + This tab allows for analysis of elementary flows. + + Example questions that can be answered by this tab: + What is the CO2 production caused by reference flow XXX? + Which impact is largest on the impact category YYY? + What are the 5 largest elementary flows caused by reference flow ZZZ? + + Shows: + Cutoff menu for determining cutoff values + Compare options button to change between 'Reference Flows' and 'Impact Categories' + 'Impact Category'/'Reference Flow' chooser with aggregation method + Plot/Table on/off and Relative/Absolute options for data + Plot/Table + Export options + """ + + def __init__(self, parent=None): + super().__init__(parent) + + header = get_header_layout_w_help("Elementary Flow Contributions", self.help_button) + self.layout.addLayout(header) + self.layout.addWidget(self.cutoff_menu) + self.layout.addWidget(horizontal_line()) + combobox = self.build_combobox(has_method=True, has_func=True) + self.layout.addLayout(combobox) + self.layout.addWidget(horizontal_line()) + self.layout.addWidget(self.build_main_space()) + self.layout.addLayout(self.build_export(True, True)) + + self.contribution_fn = "EF contributions" + self.switches.configure(self.has_func, self.has_method) + self.connect_signals() + self.toggle_comparisons(self.switches.indexes.func) + + def build_combobox( + self, has_method: bool = True, has_func: bool = False + ) -> QtWidgets.QHBoxLayout: + self.combobox_menu.agg.addItems(self.parent.contributions.DEFAULT_EF_AGGREGATES) + return super().build_combobox(has_method, has_func) + + def update_dataframe(self, *args, **kwargs): + """Retrieve the top elementary flow contributions.""" + return self.parent.contributions.top_elementary_flow_contributions( + **kwargs, + limit=self.cutoff_menu.cutoff_value, + limit_type=self.cutoff_menu.limit_type, + normalize=self.relative, + total_range=self.total_range, + ) + + +class ProcessContributionsTab(ContributionTab): + """Class for the 'Process Contributions' sub-tab. + + This tab allows for analysis of process contributions. + + Example questions that can be answered by this tab: + What is the contribution of electricity production to reference flow XXX? + Which process contributes the most to impact category YYY? + What are the top 5 contributing processes to reference flow ZZZ? + + Shows: + Cutoff menu for determining cutoff values + Compare options button to change between 'Reference Flows' and 'Impact Categories' + 'Impact Category'/'Reference Flow' chooser with aggregation method + Plot/Table on/off and Relative/Absolute options for data + Plot/Table + Export options + """ + + def __init__(self, parent=None): + super().__init__(parent) + + header = get_header_layout_w_help("Process Contributions", self.help_button) + self.layout.addLayout(header) + self.layout.addWidget(self.cutoff_menu) + self.layout.addWidget(horizontal_line()) + combobox = self.build_combobox(has_method=True, has_func=True) + self.layout.addLayout(combobox) + self.layout.addWidget(horizontal_line()) + self.layout.addWidget(self.build_main_space()) + self.layout.addLayout(self.build_export(True, True)) + + self.contribution_fn = "Process contributions" + self.switches.configure(self.has_func, self.has_method) + self.connect_signals() + self.toggle_comparisons(self.switches.indexes.func) + + def build_combobox( + self, has_method: bool = True, has_func: bool = False + ) -> QtWidgets.QHBoxLayout: + self.combobox_menu.agg.addItems( + self.parent.contributions.DEFAULT_ACT_AGGREGATES + ) + return super().build_combobox(has_method, has_func) + + def update_dataframe(self, *args, **kwargs): + """Retrieve the top process contributions""" + return self.parent.contributions.top_process_contributions( + **kwargs, + limit=self.cutoff_menu.cutoff_value, + limit_type=self.cutoff_menu.limit_type, + normalize=self.relative, + total_range=self.total_range, + ) + + +class FirstTierContributionsTab(ContributionTab): + """Class for the 'First Tier Contributions' sub-tab. + + This tab allows for analysis of first-tier (product) contributions. + The direct impact (from biosphere exchanges from the FU) + and cumulative impacts from all exchange inputs to the FU (first level) are calculated. + + e.g. the direct emissions from steel production and the cumulative impact for all electricity input + into that activity. This works on the basis of input products and their total (cumulative) impact, scaled to + how much of that product is needed in the FU. + + Example questions that can be answered by this tab: + What is the contribution of electricity (product) to reference flow XXX? + Which input product contributes the most to impact category YYY? + What products contribute most to reference flow ZZZ? + + Shows: + Compare options button to change between 'Reference Flows' and 'Impact Categories' + 'Impact Category'/'Reference Flow' chooser with aggregation method + Plot/Table on/off and Relative/Absolute options for data + Plot/Table + Export options + """ + + def __init__(self, cs_name, parent=None): + super().__init__(parent) + + self.cache = {"scores": {}, "ranges": {}} # We cache the calculated data, as it can take some time to generate. + # We cache the individual calculation results, as they are re-used in multiple views + # e.g. FU1 x method1 x scenario1 + # may be seen in both 'Reference Flows' and 'Impact Categories', just with different axes. + # we also cache scores/ranges, not for calculation speed, but to be able to easily convert for relative results + self.caching = True # set to False to disable caching for debug + + header = get_header_layout_w_help("First Tier Contributions", self.help_button) + self.layout.addLayout(header) + self.layout.addWidget(self.cutoff_menu) + self.layout.addWidget(horizontal_line()) + combobox = self.build_combobox(has_method=True, has_func=True) + self.layout.addLayout(combobox) + self.layout.addWidget(horizontal_line()) + self.layout.addWidget(self.build_main_space()) + self.layout.addLayout(self.build_export(True, True)) + + # get relevant data from calculation setup + self.cs = cs_name + func_units = bd.calculation_setups[self.cs]["inv"] + self.func_keys = [list(fu.keys())[0] for fu in func_units] # extract a list of keys from the functional units + self.func_units = [ + {bd.get_activity(k): v for k, v in fu.items()} + for fu in func_units + ] + self.methods = bd.calculation_setups[self.cs]["ia"] + + self.contribution_fn = "First Tier contributions" + self.switches.configure(self.has_func, self.has_method) + self.connect_signals() + self.toggle_comparisons(self.switches.indexes.func) + + def update_tab(self): + """Update the tab.""" + if self.has_been_opened: + super().update_tab() + + def build_combobox( + self, has_method: bool = True, has_func: bool = False + ) -> QtWidgets.QHBoxLayout: + self.combobox_menu.agg.addItems( + self.parent.contributions.DEFAULT_ACT_AGGREGATES + ) + return super().build_combobox(has_method, has_func) + + def get_data(self, compare) -> List[list]: + """Get the data for analysis, either from self.cache or from calculation.""" + def try_cache(): + """Get data from cache if exists, otherwise return none.""" + if self.caching: + return self.cache.get(cache_key, None) + + def calculate(): + """Shorthand for getting calculation results.""" + return self.calculate_contributions(demand, demand_key, demand_index, + method=method, method_index=method_index, + scenario_lca=self.has_scenarios, scenario_index=scenario_index, + ) + + # get the right data + if self.has_scenarios: + # get the scenario index, if it is -1 (none selected), then use index first index (0) + scenario_index = max(self.combobox_menu.scenario.currentIndex(), 0) + else: + scenario_index = None + method_index = self.combobox_menu.method.currentIndex() + method = self.methods[method_index] + demand_index = self.combobox_menu.func.currentIndex() + demand = self.func_units[demand_index] + demand_key = self.func_keys[demand_index] + + all_data = [] + if compare == "Reference Flows": + # run the analysis for every reference flow + for demand_index, demand in enumerate(self.func_units): + demand_key = self.func_keys[demand_index] + cache_key = (demand_index, method_index, scenario_index) + # get data from cache if exists, otherwise calculate + if data := try_cache(): + all_data.append([demand_key, data]) + continue + + data = calculate() + if self.caching: + self.cache[cache_key] = data + all_data.append([demand_key, data]) + elif compare == "Impact Categories": + # run the analysis for every method + for method_index, method in enumerate(self.methods): + cache_key = (demand_index, method_index, scenario_index) + + # get data from cache if exists, otherwise calculate + if data := try_cache(): + all_data.append([method, data]) + continue + + data = calculate() + if self.caching: + self.cache[cache_key] = data + all_data.append([method, data]) + elif compare == "Scenarios": + # run the analysis for every scenario + for scenario_index in range(self.combobox_menu.scenario.count()): + scenario = self.combobox_menu.scenario.itemText(scenario_index) + cache_key = (demand_index, method_index, scenario_index) + + # get data from cache if exists, otherwise calculate + if data := try_cache(): + all_data.append([scenario, data]) + continue + + data = calculate() + if self.caching: + self.cache[cache_key] = data + all_data.append([scenario, data]) + + return all_data + + def calculate_contributions(self, demand, demand_key, demand_index, + method, method_index: int = None, + scenario_lca: bool = False, scenario_index: int = None) -> dict: + """Retrieve relevant activity data and calculate first tier contributions.""" + + def get_default_demands() -> dict: + """Get the inputs to calculate contributions from the activity""" + # get exchange keys leading to this activity + technosphere = bd.get_activity(demand_key).technosphere() + + keys = [exch.input.key for exch in technosphere if + exch.input.key != exch.output.key] + # find scale from production amount and demand amount + scale = demand[demand_key] / [p for p in bd.get_activity(demand_key).production()][0].amount + + amounts = [exch.amount * scale for exch in technosphere if + exch.input.key != exch.output.key] + demands = {keys[i]: amounts[i] for i, _ in enumerate(keys)} + return demands + + def get_scenario_demands() -> dict: + """Get the inputs to calculate contributions from the scenario matrix""" + # get exchange keys leading to this activity + technosphere = bd.get_activity(demand_key).technosphere() + demand_idx = _lca.product_dict[demand_key] + + keys = [exch.input.key for exch in technosphere if + exch.input.key != exch.output.key] + # find scale from production amount and demand amount + scale = demand[demand_key] / _lca.technosphere_matrix[_lca.activity_dict[demand_key], demand_idx] * -1 + + amounts = [] + + for exch in technosphere: + exch_idx = _lca.activity_dict[exch.input.key] + if exch.input.key != exch.output.key: + amounts.append(_lca.technosphere_matrix[exch_idx, demand_idx] * scale) + + # write al non-zero exchanges to demand dict + demands = {keys[i]: amounts[i] for i, _ in enumerate(keys) if amounts[i] != 0} + return demands + + # reuse LCA object from original calculation to skip 1 LCA + if scenario_lca: + # get score from the already calculated result + score = self.parent.mlca.lca_scores[demand_index, method_index, scenario_index] + + # get lca object from mlca class + self.parent.mlca.current = scenario_index + self.parent.mlca.update_matrices() + _lca = self.parent.mlca.lca + _lca.redo_lci(demand) + + else: + # get score from the already calculated result + score = self.parent.mlca.lca_scores[demand_index, method_index] + + # get lca object to calculate new results + _lca = self.parent.mlca.lca + + # set the correct method + _lca.switch_method(method) + _lca.lcia_calculation() + + if score == 0: + # no need to calculate contributions to '0' score + # technically it could be that positive and negative score of same amount negate to 0, but highly unlikely. + return {"Score": 0, "Range": 0, demand_key: 0} + + data = {"Score": score} + _range = [] + remainder = score # contribution of demand_key + + if not scenario_lca: + new_demands = get_default_demands() + else: + new_demands = get_scenario_demands() + + # iterate over all activities demand_key is connected to + for key, amt in new_demands.items(): + + # recalculate for this demand + _lca.redo_lci({key: amt}) + _lca.redo_lcia() + + score = _lca.score + if score != 0: + # only store non-zero results + data[key] = score + _range.append(abs(score)) + remainder -= score # subtract this from remainder + + data[demand_key] = remainder + _range.append(abs(remainder)) + data["Range"] = sum(_range) + return data + + def key_to_metadata(self, key: tuple) -> list: + """Convert the key information to list with metadata. + + format: + [reference product, activity name, location, unit, database] + """ + return list(app.metadata.get_metadata([key], ["reference product", "name", "location", "unit"]).iloc[0]) + [key[0]] + + def metadata_to_index(self, data: list) -> str: + """Convert list to formatted index. + + format: + reference product | activity name | location | unit | database + """ + return " | ".join(data) + + def data_to_df(self, all_data: List[list], compare: str) -> pd.DataFrame: + """Convert the provided data into a dataframe.""" + unique_keys = set() + # get all the unique keys: + d = {"index": [], "reference product": [], "name": [], + "location": [], "unit": [], "database": []} + meta_cols = set(d.keys()) + + for i, (item, data) in enumerate(all_data): + # item is a key, method or scenario depending on the `compares` + unique_keys.update(data.keys()) + # already add the total with right column formatting depending on `compares` + if compare == "Reference Flows": + col_name = self.metadata_to_index(self.key_to_metadata(item)) + elif compare == "Impact Categories": + col_name = self.metadata_to_index(list(item)) + elif compare == "Scenarios": + col_name = item + + self.cache["scores"][col_name] = data["Score"] + self.cache["ranges"][col_name] = data["Range"] + d[col_name] = [] + + all_data[i] = item, data, col_name + + if compare == "Impact Categories": + self.unit = get_unit(method=False, relative=self.relative) + else: + self.unit = get_unit(self.parent.method_dict[self.combobox_menu.method.currentText()], self.relative) + + # convert to dict format to feed into dataframe + for key in unique_keys: + if key in ["Score", "Range"]: + continue + # get metadata + metadata = self.key_to_metadata(key) + d["index"].append(self.metadata_to_index(metadata)) + d["reference product"].append(metadata[0]) + d["name"].append(metadata[1]) + d["location"].append(metadata[2]) + d["unit"].append(self.unit) + d["database"].append(metadata[4]) + # check for each dataset if we have values, otherwise add np.nan + for item, data, col_name in all_data: + if val := data.get(key, False): + value = val + else: + value = np.nan + d[col_name].append(value) + + df = pd.DataFrame(d) + data_cols = [col for col in df if col not in meta_cols] + df = df.dropna(subset=data_cols, how="all") + + # now, apply aggregation + group_on = self.combobox_menu.agg.currentText() + if group_on != "none": + df = df.groupby(by=group_on, as_index=False).sum() + df["index"] = df[group_on] + df = df[["index"] + data_cols] + meta_cols = ["index"] + + all_contributions = deepcopy(df) + + # now, apply cut-off + limit_type = self.cutoff_menu.limit_type + limit = self.cutoff_menu.cutoff_value + + # iterate over the columns to get contributors, then replace cutoff flows with nan + # nested for is slow, but this should rarely have to deal with >>50 rows (rows == technosphere exchanges) + contributors = df[data_cols].shape[0] + for col_num, col in enumerate(df[data_cols].T.values): + # now, get total: + if self.total_range: # total is based on the range + total = np.nansum(np.abs(col)) + else: # total is based on the score + total = np.nansum(col) + + col = np.nan_to_num(col) # replace nan with 0 + cont = ca.sort_array(col, limit=limit, limit_type=limit_type, total=total) + # write nans to values not present in cont + for row_num in range(contributors): + if row_num not in cont[:, 1]: + df.iloc[row_num, col_num + len(meta_cols)] = np.nan + + # drop any rows not contributing to anything + df = df.dropna(subset=data_cols, how="all") + + # sort by mean square of each row + func = lambda row: np.nanmean(np.square(row)) + if len(df) > 1: # but only sort if there is something to sort + df["_sort_me_"] = df[data_cols].apply(func, axis=1) + df.sort_values(by="_sort_me_", ascending=False, inplace=True) + del df["_sort_me_"] + + # add the scores and rest values + score_and_rest = {col: [] for col in df} + for col in df: + if col == "index": + score_and_rest[col].extend(["Score", "Rest (+)", "Rest (-)"]) + elif col in data_cols: + # score + score = self.cache["scores"][col] + # positive and negative rest values + pos_rest = (np.sum((all_contributions[col].values)[all_contributions[col].values > 0]) + - np.sum((df[col].values)[df[col].values > 0])) + neg_rest = (np.sum((all_contributions[col].values)[all_contributions[col].values < 0]) + - np.sum((df[col].values)[df[col].values < 0])) + + score_and_rest[col].extend([score, pos_rest, neg_rest]) + else: + score_and_rest[col].extend(["", "", ""]) + + # add the two df together + df = pd.concat([pd.DataFrame(score_and_rest), df], axis=0) + + # normalize + if self.relative: + if self.total_range: + normalize = [self.cache["ranges"][col] for col in data_cols] + else: + normalize = [self.cache["scores"][col] for col in data_cols] + df[data_cols] = df[data_cols] / normalize + + return df + + def update_dataframe(self, *args, **kwargs): + """Retrieve the product contributions.""" + + compare = self.switches.currentText() + + all_data = self.get_data(compare) + df = self.data_to_df(all_data, compare) + return df + + +class CorrelationsTab(NewAnalysisTab): + def __init__(self, parent): + super().__init__(parent) + self.parent = parent + + self.tab_text = "Correlations" + self.layout.addLayout(get_header_layout("Correlation Analysis")) + + if not self.parent.single_func_unit: + self.plot = CorrelationPlot(self.parent) + + self.layout.addWidget(self.build_main_space()) + self.layout.addLayout( + self.build_export( + has_table=False, has_plot=not self.parent.single_func_unit + ) + ) + + def update_plot(self): + """Update the plot.""" + idx = self.pt_layout.indexOf(self.plot) + self.plot.figure.clf() + self.plot.setVisible(False) + self.plot.deleteLater() + self.plot = CorrelationPlot(self.parent) + self.pt_layout.insertWidget(idx, self.plot) + df = self.parent.mlca.get_normalized_scores_df() + super().update_plot(df) + if self.pt_layout.parentWidget(): + self.pt_layout.parentWidget().updateGeometry() + + +class MonteCarloTab(NewAnalysisTab): + def __init__(self, parent=None): + super(MonteCarloTab, self).__init__(parent) + self.parent: LCAResultsSubTab = parent + header_ = QtWidgets.QToolBar() + _header = header("Monte Carlo Simulation") + _header.setToolTip("Left click on the question mark for help") + header_.addWidget(_header) + header_.addAction( + icons.qicons.question, + "Left click for help on Monte Carlo analysis", + self.explanation, + ) + self.layout.addWidget(header_) + self.scenario_label = QtWidgets.QLabel("Scenario:") + self.include_box = QtWidgets.QGroupBox("Include uncertainty for:", self) + grid = QtWidgets.QGridLayout() + self.include_tech = QtWidgets.QCheckBox("Technosphere", self) + self.include_tech.setChecked(True) + self.include_bio = QtWidgets.QCheckBox("Biosphere", self) + self.include_bio.setChecked(True) + self.include_cf = QtWidgets.QCheckBox("Characterization Factors", self) + self.include_cf.setChecked(False) + self.include_cf.setEnabled(False) + self.include_parameters = QtWidgets.QCheckBox("Parameters", self) + self.include_parameters.setChecked(False) + self.include_parameters.setEnabled(False) + grid.addWidget(self.include_tech, 0, 0) + grid.addWidget(self.include_bio, 0, 1) + grid.addWidget(self.include_cf, 1, 0) + grid.addWidget(self.include_parameters, 1, 1) + self.include_box.setLayout(grid) + + self.add_MC_ui_elements() + + self.table = LCAResultsTable() + self.table.table_name = "MonteCarlo_" + self.parent.cs_name + self.plot = MonteCarloPlot(self.parent) + self.plot.hide() + self.plot.plot_name = "MonteCarlo_" + self.parent.cs_name + self.layout.addWidget(self.plot) + self.export_widget = self.build_export(has_plot=True, has_table=True) + self.layout.addWidget(self.export_widget) + self.layout.setAlignment(QtCore.Qt.AlignTop) + self.connect_signals() + self.explain_text = """ +

Monte Carlo Analyses

+

Monte Carlo simulations generate stochastic data samples using existing data defined parameter + distributions for generating the expected distribution for the reference flows.

+

More simply, within the LCA model the user may define certain uncertainty distributions for some + (or all) parameters. Monte Carlo analysis uses these defined uncertainty distributions with a stochastic + generator to sample from these distributions. This results in a "posterior" (or final) probability + distribution, expressing the expected variance, for the reference flows.

+

More + information can be found here

+ """ + + def connect_signals(self): + self.button_run.clicked.connect(self.calculate_mc_lca) + # signals.monte_carlo_ready.connect(self.update_mc) + # self.combobox_fu.currentIndexChanged.connect(self.update_plot) + self.combobox_methods.currentIndexChanged.connect( + # ignore the index and send the cs_name instead + lambda x: self.update_mc(cs_name=self.parent.cs_name) + ) + + # signals + # self.radio_button_biosphere.clicked.connect(self.button_clicked) + # self.radio_button_technosphere.clicked.connect(self.button_clicked) + + if self.has_scenarios: + self.scenario_box.currentIndexChanged.connect( + self.parent.update_scenario_data + ) + self.parent.update_scenario_box_index.connect( + lambda index: self.set_combobox_index(self.scenario_box, index) + ) + + def add_MC_ui_elements(self): + layout_mc = QtWidgets.QVBoxLayout() + + # H-LAYOUT start simulation + self.button_run = QtWidgets.QPushButton("Run") + self.label_iterations = QtWidgets.QLabel("Iterations:") + self.iterations = QtWidgets.QLineEdit("30") + self.iterations.setFixedWidth(40) + self.iterations.setValidator(QtGui.QIntValidator(1, 1000)) + self.label_seed = QtWidgets.QLabel("Random seed:") + self.label_seed.setToolTip( + "Seed value (integer) for the random number generator. " + "Use this for reproducible samples." + ) + self.seed = QtWidgets.QLineEdit("") + self.seed.setFixedWidth(30) + + self.hlayout_run = QtWidgets.QHBoxLayout() + self.hlayout_run.addWidget(self.scenario_label) + self.hlayout_run.addWidget(self.scenario_box) + self.hlayout_run.addWidget(self.button_run) + self.hlayout_run.addWidget(self.label_iterations) + self.hlayout_run.addWidget(self.iterations) + self.hlayout_run.addWidget(self.label_seed) + self.hlayout_run.addWidget(self.seed) + self.hlayout_run.addWidget(self.include_box) + self.hlayout_run.addStretch(1) + layout_mc.addLayout(self.hlayout_run) + + # self.label_running = QLabel('Running a Monte Carlo simulation. Please allow some time for this. ' + # 'Please do not run another simulation at the same time.') + # self.layout_mc.addWidget(self.label_running) + # self.label_running.hide() + + # # buttons for all FUs or for all methods + # self.radio_button_all_fu = QRadioButton("For all reference flows") + # self.radio_button_all_methods = QRadioButton("Technosphere flows") + # + # self.radio_button_biosphere.setChecked(True) + # self.radio_button_technosphere.setChecked(False) + # + # self.label_for_all_fu = QLabel('For all reference flows') + # self.combobox_fu = QRadioButton() + # self.hlayout_fu = QHBoxLayout() + + # FU selection + # self.label_fu = QLabel('Choose reference flow') + # self.combobox_fu = QComboBox() + # self.hlayout_fu = QHBoxLayout() + # + # self.hlayout_fu.addWidget(self.label_fu) + # self.hlayout_fu.addWidget(self.combobox_fu) + # self.hlayout_fu.addStretch() + # self.layout_mc.addLayout(self.hlayout_fu) + + # method selection + self.method_selection_widget = QtWidgets.QWidget() + self.label_methods = QtWidgets.QLabel("Choose impact category") + self.combobox_methods = QtWidgets.QComboBox() + self.hlayout_methods = QtWidgets.QHBoxLayout() + + self.hlayout_methods.addWidget(self.label_methods) + self.hlayout_methods.addWidget(self.combobox_methods) + self.hlayout_methods.addStretch() + self.method_selection_widget.setLayout(self.hlayout_methods) + + layout_mc.addWidget(self.method_selection_widget) + self.method_selection_widget.hide() + + self.layout.addLayout(layout_mc) + + def build_export(self, has_table: bool = True, has_plot: bool = True) -> QtWidgets.QWidget: + """Construct the export layout but set it into a widget because we + want to hide it.""" + export_layout = super().build_export(has_table, has_plot) + export_widget = QtWidgets.QWidget() + export_widget.setLayout(export_layout) + # Hide widget until MC is calculated + export_widget.hide() + return export_widget + + @QtCore.Slot(name="calculateMcLca") + def calculate_mc_lca(self): + self.method_selection_widget.hide() + self.plot.hide() + self.export_widget.hide() + + iterations = int(self.iterations.text()) + seed = None + if self.seed.text(): + logger.info(f"SEED: {self.seed.text()}") + try: + seed = int(self.seed.text()) + except ValueError as e: + logger.error( + "Seed value must be an integer number or left empty.", exc_info=e + ) + QtWidgets.QMessageBox.warning( + self, + "Warning", + "Seed value must be an integer number or left empty.", + ) + self.seed.setText("") + return + includes = { + "technosphere": self.include_tech.isChecked(), + "biosphere": self.include_bio.isChecked(), + "cf": self.include_cf.isChecked(), + "parameters": self.include_parameters.isChecked(), + } + + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + try: + self.parent.mc.calculate(iterations=iterations, seed=seed, **includes) + app.signals.monte_carlo_finished.emit() + self.update_mc() + except ( + InvalidParamsError + ) as e: # This can occur if uncertainty data is missing or otherwise broken + # print(e) + logger.error(e) + QtWidgets.QMessageBox.warning( + self, "Could not perform Monte Carlo simulation", str(e) + ) + QtWidgets.QApplication.restoreOverrideCursor() + + # a threaded way for this - unfortunatley this crashes as: + # pypardsio_solver is used for the 'spsolve' and 'factorized' functions. Python crashes on windows if multiple + # instances of PyPardisoSolver make calls to the Pardiso library + # worker_thread = WorkerThread() + # print('Created local worker_thread') + # worker_thread.set_mc(self.parent.mc, iterations=iterations) + # print('Passed object to thread.') + # worker_thread.start() + # self.label_running.show() + + # + + # thread = NewCSMCThread() #self.parent.mc + # thread.calculation_finished.connect( + # lambda x: print('Calculation finished.')) + # thread.start() + + # # give us a thread and start it + # thread = QtCore.QThread() + # thread.start() + # + # # create a worker and move it to our extra thread + # worker = Worker() + # worker.moveToThread(thread) + + # self.parent.mct.start() + # self.parent.mct.run(iterations=iterations) + # self.parent.mct.finished() + + # objThread = QtCore.QThread() + # obj = QObjectMC() # self.parent.cs_name + # obj.moveToThread(objThread) + # obj.finished.connect(objThread.quit) + # objThread.started.connect(obj.long_running) + # # objThread.finished.connect(app.exit) + # objThread.finished.connect( + # lambda x: print('Finished Thread!') + # ) + # objThread.start() + + # objThread = QtCore.QThread() + # obj = SomeObject() + # obj.moveToThread(objThread) + # obj.finished.connect(objThread.quit) + # objThread.started.connect(obj.long_running) + # objThread.finished.connect( + # lambda x: print('Finished Thread!') + # ) + # objThread.start() + + # self.method_selection_widget.show() + # self.plot.show() + # self.export_widget.show() + + def configure_scenario(self): + super().configure_scenario() + self.scenario_label.setVisible(self.has_scenarios) + + def update_tab(self): + self.update_combobox( + self.combobox_methods, [str(m) for m in self.parent.mc.methods] + ) + # self.update_combobox(self.combobox_methods, [str(m) for m in self.parent.mct.mc.methods]) + + def update_mc(self, cs_name=None): + # act = self.combobox_fu.currentText() + # activity_index = self.combobox_fu.currentIndex() + # act_key = self.parent.mc.activity_keys[activity_index] + # if cs_name != self.parent.cs_name: # relevant if several CS are open at the same time + # return + + # self.label_running.hide() + self.method_selection_widget.show() + self.export_widget.show() + + method_index = self.combobox_methods.currentIndex() + method = self.parent.mc.methods[method_index] + + # data = self.parent.mc.get_results_by(act_key=act_key, method=method) + self.df = self.parent.mc.get_results_dataframe(method=method) + + self.update_table() + self.update_plot(method=method) + filename = "_".join( + [str(x) for x in [self.parent.cs_name, "Monte Carlo results", str(method)]] + ) + self.plot.plot_name, self.table.table_name = filename, filename + + def update_plot(self, method): + idx = self.layout.indexOf(self.plot) + self.plot.figure.clf() + self.plot.setVisible(False) + self.plot.deleteLater() + # name is already altered by update_mc before update_plot + name = self.plot.plot_name + self.plot = MonteCarloPlot(self.parent) + self.layout.insertWidget(idx, self.plot) + super().update_plot(self.df, method=method) + self.plot.plot_name = name + self.plot.show() + if self.layout.parentWidget(): + self.layout.parentWidget().updateGeometry() + + def update_table(self): + super().update_table(self.df) + + +class GSATab(NewAnalysisTab): + def __init__(self, parent=None): + super(GSATab, self).__init__(parent) + self.parent = parent + + self.GSA = GlobalSensitivityAnalysis(self.parent.mc) + + header_ = QtWidgets.QToolBar() + _header = header("Global Sensitivity Analysis") + _header.setToolTip("Left click on the question mark for help") + header_.addWidget(_header) + header_.addAction( + icons.qicons.question, + "Left click for help on Global Sensitivity Analysis", + self.explanation, + ) + + self.layout.addWidget(header_) + self.scenario_box = None + + self.add_GSA_ui_elements() + + self.table = LCAResultsTable() + self.table.table_name = "GSA_" + self.parent.cs_name + self.layout.addWidget(self.table) + self.table.hide() + # self.plot = MonteCarloPlot(self.parent) + # self.plot.hide() + # self.plot.plot_name = 'GSA_' + self.parent.cs_name + # self.layout.addWidget(self.plot) + + self.export_widget = self.build_export(has_plot=False, has_table=True) + self.layout.addWidget(self.export_widget) + self.layout.setAlignment(QtCore.Qt.AlignTop) + self.connect_signals() + + self.explain_text = """ +

Global Sensitivity Analysis (GSA) is a family of methods that, used in conjunction with distribution + generating functions, can investigate the contributions of model variables on the final results.

+

Within the AB running a GSA depends on the use of a Monte Carlo simulation for generating the + variable distributions for the reference flow(s), upon which the GSA is performed. Running the GSA executes + the stochastic simulations whilst fixing the values of selected variables of interest. Taking a lower and + upper bound for the variables, therefore, indicates the influence of the fixed variable on the overall + level of model variability.

+

For a more detailed explanation see the wiki

+

The paper describing the methods is published by Wiley online

+ """ + + def connect_signals(self): + self.button_run.clicked.connect(self.calculate_gsa) + app.signals.monte_carlo_finished.connect(self.monte_carlo_finished) + + def add_GSA_ui_elements(self): + # H-LAYOUT SETTINGS ROW 1 + + # run button + self.button_run = QtWidgets.QPushButton("Run") + self.button_run.setEnabled(False) + + # reference flow selection + self.label_fu = QtWidgets.QLabel("Reference Flow:") + self.combobox_fu = QtWidgets.QComboBox() + + # method selection + self.label_methods = QtWidgets.QLabel("Impact Category:") + self.combobox_methods = QtWidgets.QComboBox() + + # arrange layout + self.hlayout_row1 = QtWidgets.QHBoxLayout() + self.hlayout_row1.addWidget(self.button_run) + self.hlayout_row1.addWidget(self.label_fu) + self.hlayout_row1.addWidget(self.combobox_fu) + self.hlayout_row1.addWidget(self.label_methods) + self.hlayout_row1.addWidget(self.combobox_methods) + + # self.hlayout_row1.addWidget(self.fu_selection_widget) + # self.hlayout_row1.addWidget(self.method_selection_widget) + self.hlayout_row1.addStretch(1) + + # H-LAYOUT SETTINGS ROW 2 + self.hlayout_row2 = QtWidgets.QHBoxLayout() + + # cutoff technosphere + self.label_cutoff_technosphere = QtWidgets.QLabel("Cut-off technosphere:") + self.cutoff_technosphere = QtWidgets.QLineEdit("0.01") + self.cutoff_technosphere.setFixedWidth(40) + self.cutoff_technosphere.setValidator(QtGui.QDoubleValidator(0.0, 1.0, 5)) + + # cutoff biosphere + self.label_cutoff_biosphere = QtWidgets.QLabel("Cut-off biosphere:") + self.cutoff_biosphere = QtWidgets.QLineEdit("0.01") + self.cutoff_biosphere.setFixedWidth(40) + self.cutoff_biosphere.setValidator(QtGui.QDoubleValidator(0.0, 1.0, 5)) + + # export GSA input/output data automatically with run + self.checkbox_export_data_automatically = QtWidgets.QCheckBox( + "Save input/output data to Excel after run" + ) + self.checkbox_export_data_automatically.setChecked(False) + + # # exclude Pedigree + # self.checkbox_pedigree = QCheckBox('Include Pedigree uncertainties') + # self.checkbox_pedigree.setChecked(True) + + # arrange layout + self.hlayout_row2.addWidget(self.label_cutoff_technosphere) + self.hlayout_row2.addWidget(self.cutoff_technosphere) + self.hlayout_row2.addWidget(self.label_cutoff_biosphere) + self.hlayout_row2.addWidget(self.cutoff_biosphere) + self.hlayout_row2.addWidget(self.checkbox_export_data_automatically) + # self.hlayout_row2.addWidget(self.checkbox_pedigree) + self.hlayout_row2.addStretch(1) + + # OVERALL LAYOUT OF SETTINGS + self.layout_settings = QtWidgets.QVBoxLayout() + self.layout_settings.addLayout(self.hlayout_row1) + self.layout_settings.addLayout(self.hlayout_row2) + self.widget_settings = QtWidgets.QWidget() + self.widget_settings.setLayout(self.layout_settings) + + # add to GSA layout + self.label_monte_carlo_first = QtWidgets.QLabel( + "You need to run a Monte Carlo Simulation first." + ) + self.layout.addWidget(self.label_monte_carlo_first) + self.layout.addWidget(self.widget_settings) + + # at start + # todo: this is just for development, should be reversed later: + self.widget_settings.hide() + # self.label_monte_carlo_first.hide() + + def update_tab(self): + self.update_combobox( + self.combobox_methods, [str(m) for m in self.parent.mc.methods] + ) + self.update_combobox( + self.combobox_fu, list(self.parent.mlca.func_unit_translation_dict.keys()) + ) + + def monte_carlo_finished(self): + self.button_run.setEnabled(True) + self.widget_settings.show() + self.label_monte_carlo_first.hide() + + def calculate_gsa(self): + act_number = self.combobox_fu.currentIndex() + method_number = self.combobox_methods.currentIndex() + cutoff_technosphere = float(self.cutoff_technosphere.text()) + cutoff_biosphere = float(self.cutoff_biosphere.text()) + # print('Calculating GSA for: ', act_number, method_number, cutoff_technosphere, cutoff_biosphere) + + try: + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + self.GSA.perform_GSA( + act_number=act_number, + method_number=method_number, + cutoff_technosphere=cutoff_technosphere, + cutoff_biosphere=cutoff_biosphere, + ) + # self.update_mc() + except Exception as e: + import traceback + traceback.print_tb(e.__traceback__) + logger.error(e) + message = str(e) + message_addition = "" + if message == "singular matrix": + message_addition = "\nIn order to avoid this happening, please increase the Monte Carlo iterations (e.g. to above 50)." + elif message == "`dataset` input should have multiple elements.": + message_addition = "\nIn order to avoid this happening, please increase the Monte Carlo iterations (e.g. to above 50)." + elif message == "No objects to concatenate": + message_addition = ( + "\nThe reason for this is likely that there are no uncertain exchanges. Please check " + "the checkboxes in the Monte Carlo tab." + ) + QtWidgets.QMessageBox.warning( + self, "Could not perform GSA", str(message) + message_addition + ) + QtWidgets.QApplication.restoreOverrideCursor() + + self.update_gsa() + + def update_gsa(self, cs_name=None): + self.df = getattr(self.GSA, "df_final", None) + if self.df is None: + return + self.update_table() + self.table.show() + self.export_widget.show() + + self.table.table_name = "gsa_output_" + self.GSA.get_save_name() + + if self.checkbox_export_data_automatically.isChecked(): + logger.info("EXPORTING DATA") + self.GSA.export_GSA_input() + self.GSA.export_GSA_output() + + def update_plot(self, method): + pass + + def update_table(self): + super().update_table(self.df) + + def build_export(self, has_table: bool = True, has_plot: bool = True) -> QtWidgets.QWidget: + """Construct the export layout but set it into a widget because we + want to hide it.""" + export_layout = super().build_export(has_table, has_plot) + export_widget = QtWidgets.QWidget() + export_widget.setLayout(export_layout) + # Hide widget until MC is calculated + export_widget.hide() + return export_widget + + # TODO review if can be removed + # def set_filename(self, optional_fields: dict = None): + # """Given a dictionary of fields, put together a usable filename for the plot and table.""" + # save_name = 'gsa_output_' + self.mc.cs_name + '_' + str(self.mc.iterations) + '_' + self.activity['name'] + \ + # '_' + str(self.method) + '.xlsx' + # save_name = save_name.replace(',', '').replace("'", '').replace("/", '') + # self.table.table_name = save_name + # optional = optional_fields or {} + # fields = ( + # self.parent.cs_name, self.contribution_fn, optional.get("method"), + # optional.get("functional_unit"), self.unit + # ) + # filename = '_'.join((str(x) for x in fields if x is not None)) + + +class MonteCarloWorkerThread(QtCore.QThread): + """A worker for Monte Carlo simulations. + + Unfortunately, pyparadiso does not allow parallel calculations on Windows (crashes). + So this is for future reference in case this issue is solved...""" + + def __init__(self): + pass + + def set_mc(self, mc, iterations=20): + self.mc = mc + self.iterations = iterations + + def run(self): + logger.info(f"Starting new Worker Thread. Iterations: {self.iterations}") + self.mc.calculate(iterations=self.iterations) + # res = bw.GraphTraversal().calculate(self.demand, self.method, self.cutoff, self.max_calc) + logger.info("in thread {}".format(QtCore.QThread.currentThread())) + app.signals.monte_carlo_ready.emit(self.mc.cs_name) + + +worker_thread = MonteCarloWorkerThread() + +# TODO review if can be removed + +# class Worker(QtCore.QObject): +# +# def __init__(self): +# super().__init__() +# +# def do_something(self, text): +# print('in thread {} message {}'.format(QtCore.QThread.currentThread(), text)) +# +# +# class SomeObject(QtCore.QObject): +# +# finished = QtCore.pyqtSignal() +# +# def long_running(self): +# count = 0 +# while count < 5: +# time.sleep(1) +# print("B Increasing") +# count += 1 +# self.finished.emit() diff --git a/activity_browser/app/pages/lca_results/__init__.py b/activity_browser/app/pages/lca_results/__init__.py new file mode 100644 index 000000000..ce2edd78d --- /dev/null +++ b/activity_browser/app/pages/lca_results/__init__.py @@ -0,0 +1 @@ +from .LCA_results import LCAResultsPage diff --git a/activity_browser/app/pages/lca_results/dialogs.py b/activity_browser/app/pages/lca_results/dialogs.py new file mode 100644 index 000000000..2e83dd39f --- /dev/null +++ b/activity_browser/app/pages/lca_results/dialogs.py @@ -0,0 +1,574 @@ +from qtpy import QtWidgets, QtGui +from qtpy.QtCore import Qt + +from activity_browser.ui.icons import qicons + +from .style import vertical_line + + +class ColumnFilterTab(QtWidgets.QWidget): + """Content of column tab. + + Required inputs: + - None + Optional inputs: + - col_type: str --> the type of column, either 'str' or 'num'. defines the search type options. + defaults to 'str' + - state: dict --> dict of existing filter state that should be re-created in UI. + + Interaction: + - def get_state: Provides the state of all relevant filter elements (filter rows, AND/OR menu) + returns: dict + - def set_state: Writes given state dict to UI elements (filter rows, AND/OR menu) + """ + + def __init__( + self, filter_types: dict, col_type: str = "str", state: dict = {}, parent=None + ): + super().__init__(parent) + self.filter_types = filter_types + self.col_type = col_type + + self.add = QtWidgets.QToolButton() + self.add.setIcon(qicons.add) + self.add.setToolTip("Add a new filter for this column") + self.add.clicked.connect(self.add_row) + + self.and_or_buttons = AndOrRadioButtons( + label_text="Combine filters within column:" + ) + if self.col_type == "str": + self.and_or_buttons.set_state("OR") + + self.filter_rows = [] + self.filter_widget_layout = QtWidgets.QVBoxLayout() + self.filter_widget = QtWidgets.QWidget() + self.filter_widget.setLayout(self.filter_widget_layout) + + # set the state, adds 1 empty row if state=={} + self.set_state(state) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.filter_widget) + layout.addWidget(self.add) + layout.addStretch() + layout.addWidget(self.and_or_buttons) + self.setLayout(layout) + + def add_row(self, state: tuple = None) -> None: + """Add a new row to the self.filter_rows.""" + idx = len(self.filter_rows) + + if self.col_type == "num": + new_filter_row = NumFilterRow( + idx=idx, state=state, filter_types=self.filter_types, parent=self + ) + else: + # if none of the above types, assume str + new_filter_row = StrFilterRow( + idx=idx, state=state, filter_types=self.filter_types, parent=self + ) + + self.filter_rows.append(new_filter_row) + self.filter_widget_layout.addWidget(new_filter_row) + self.show_hide_and_or() + + def remove_row(self, idx: int) -> None: + """Remove the row from the setup""" + # remove the row from widget and self.filter_rows + self.filter_widget_layout.itemAt(idx).widget().deleteLater() + self.filter_rows.pop(idx) + # re-index the list of rows + for i, filter_row in enumerate(self.filter_rows): + filter_row.idx = i + # if there would be no remaining rows, add a new empty one + if len(self.filter_rows) == 0: + self.add_row() + self.show_hide_and_or() + + @property + def get_state(self) -> dict: + # check if there are filters + if len(self.filter_rows) == 0: + return None + # check if there are valid filters + valid_filters = [row.get_state for row in self.filter_rows if row.get_state] + if len(valid_filters) == 0: + return None + elif len(valid_filters) == 1: + return {"filters": valid_filters} + else: + return {"filters": valid_filters, "mode": self.and_or_buttons.get_state} + + def set_state(self, state: dict) -> None: + if not state: + self.add_row() + self.and_or_buttons.hide() + return + + # add one row per filter + filters = state["filters"] + self.filter_rows = [] + for filter_state in filters: + self.add_row(filter_state) + + # set state and show/hide the AND/OR widget + self.show_hide_and_or() + if state.get("mode", False): + self.and_or_buttons.set_state(state["mode"]) + + def show_hide_and_or(self) -> None: + if len(self.filter_rows) > 1: + self.and_or_buttons.show() + else: + self.and_or_buttons.hide() + + +class AndOrRadioButtons(QtWidgets.QWidget): + """Convenience class for managing AND/OR buttons. + + This class is purely intended for FilterManagerDialog and related, take this into account if using elsewhere. + + Required inputs: + - None + Optional inputs: + - label_text: str --> + - state: str --> str of existing AND/OR state that should be re-created in UI. + + Interaction: + - def get_state: Provides the state of AND/OR radio buttons (string of 'AND' or 'OR') + returns: str + - def set_state: Writes given AND/OR state UI element (string of 'AND' or 'OR') + """ + + def __init__(self, label_text: str = "", state: str = None, parent=None): + super().__init__(parent) + # create an AND/OR widget + layout = QtWidgets.QHBoxLayout() + self.btn_group = QtWidgets.QButtonGroup() + self.AND = QtWidgets.QRadioButton("AND") + self.OR = QtWidgets.QRadioButton("OR") + self.btn_group.addButton(self.AND) + self.btn_group.addButton(self.OR) + layout.addStretch() + layout.addWidget(QtWidgets.QLabel(label_text)) + layout.addWidget(self.AND) + layout.addWidget(self.OR) + self.setLayout(layout) + self.setToolTip( + "Choose how filters combine with each other.\n" + "AND must satisfy all filters, OR must satisfy at least one filter." + ) + + # set the state if one was given, otherwise, assume AND + if isinstance(state, str): + self.set_state(state) + else: + self.set_state("AND") + + @property + def get_state(self) -> str: + return self.btn_group.checkedButton().text() + + def set_state(self, state: str) -> None: + x = True + if state == "OR": + x = False + self.AND.setChecked(x) + self.OR.setChecked(not x) + + +class FilterRow(QtWidgets.QWidget): + """Convenience class for managing a filter input row. + + This class is purely intended for FilterManagerDialog and related, take this into account if using elsewhere. + + Required inputs: + - idx: int --> integer index in self.filter_rows of parent. Used as ID in parent + idx is the index position of this FilterRow in the list of rows in parent. + - filter_types: dict --> the types of filter available + Optional inputs: + - state: tuple --> tuple of existing filter state that should be re-created in UI. + + Interaction: + - def get_state: Provides the state of all relevant filter fields (filter type, query, case sensitive) + returns: tuple + - def set_state: Writes given state tuple to UI elements (filter type, query, case sensitive) + """ + + def __init__( + self, + idx: int, + filter_types: dict, + remove_option: bool = True, + preset_type: str = None, + parent=None, + ): + super().__init__(parent) + + self.idx = idx + self.filter_types = filter_types + self.filter_type = self.filter_types[self.column_type] + self.parent = parent + + self.row_layout = QtWidgets.QHBoxLayout() + + # create a 'filter type' combobox + self.filter_type_box = QtWidgets.QComboBox() + self.filter_type_box.addItems(self.filter_type) + # set a preset type if given + if isinstance(preset_type, str): + self.filter_type_box.setCurrentIndex(self.filter_type.index(preset_type)) + # add tooltip for every type option + for i, tt in enumerate(self.filter_types[self.column_type + "_tt"]): + self.filter_type_box.setItemData(i, tt, Qt.ToolTipRole) + + # create the filter input line + self.filter_query_line = QtWidgets.QLineEdit() + self.filter_query_line.setFocusPolicy(Qt.StrongFocus) + + if remove_option: + # add buttons to remove the row + self.remove = QtWidgets.QToolButton() + self.remove.setIcon(qicons.delete) + self.remove.setToolTip("Remove this filter") + self.remove.clicked.connect(self.self_destruct) + + @property + def get_state(self) -> tuple: + raise NotImplementedError + + def set_state(self, state: tuple) -> None: + raise NotImplementedError + + def set_input_changes(self) -> None: + raise NotImplementedError + + def self_destruct(self) -> None: + """Remove this FilterRow object from parent.""" + self.parent.remove_row(self.idx) + + +class StrFilterRow(FilterRow): + """Convenience class for managing a filter input row for 'str' type.""" + + def __init__( + self, + idx: int, + filter_types: dict, + state: tuple = None, + remove_option: bool = True, + preset_type: str = None, + parent=None, + ): + + self.column_type = "str" + super().__init__(idx, filter_types, remove_option, preset_type, parent) + + # create case-sensitive box + self.case_sensitive_text = QtWidgets.QLabel("Case Sensitive:") + self.filter_case_sensitive_check = QtWidgets.QCheckBox() + + # assemble the layout + self.row_layout.addWidget(self.filter_type_box) + self.row_layout.addWidget(self.filter_query_line) + self.row_layout.addWidget(self.case_sensitive_text) + self.row_layout.addWidget(self.filter_case_sensitive_check) + if remove_option: + # add button to remove the row + self.row_layout.addWidget(vertical_line()) + self.row_layout.addWidget(self.remove) + + self.setLayout(self.row_layout) + + # set the state if one was given + if isinstance(state, tuple): + self.set_state(state) + + self.filter_type_box.currentIndexChanged.connect(self.set_input_changes) + self.set_input_changes() + + @property + def get_state(self) -> tuple: + # remove weird whitespace from input + query_line = ( + self.filter_query_line.text() + .translate(str.maketrans("", "", "\n\t\r")) + .strip() + ) + # if valid, return a tuple with the state, otherwise, return None + if query_line == "": + return None + + selected_type = self.filter_type_box.currentText() + selected_query = self.filter_query_line.text() + case_sensitive = self.filter_case_sensitive_check.isChecked() + return selected_type, selected_query, case_sensitive + + def set_state(self, state: tuple) -> None: + selected_type, selected_query, case_sensitive = state + self.filter_type_box.setCurrentIndex(self.filter_type.index(selected_type)) + self.filter_query_line.setText(selected_query) + self.filter_case_sensitive_check.setChecked(case_sensitive) + + def set_input_changes(self) -> None: + # set tooltip to currently selected item + tt = self.filter_types[self.column_type + "_tt"][ + self.filter_type_box.currentIndex() + ] + self.filter_type_box.setToolTip(tt) + + +class NumFilterRow(FilterRow): + """Convenience class for managing a filter input row for 'num' type.""" + + def __init__( + self, + idx: int, + filter_types: dict, + state: tuple = None, + remove_option: bool = True, + preset_type: str = None, + parent=None, + ): + + self.column_type = "num" + super().__init__(idx, filter_types, remove_option, preset_type, parent) + + # add an input line in case 'between' ('<= x <=') is selected + self.filter_query_line0 = QtWidgets.QLineEdit() + self.filter_query_line0.hide() + + # set 'double' validator for input lines + self.filter_query_line0.setValidator(QtGui.QDoubleValidator()) + self.filter_query_line.setValidator(QtGui.QDoubleValidator()) + + # assemble the layout + self.row_layout.addWidget(self.filter_query_line0) + self.row_layout.addWidget(self.filter_type_box) + self.row_layout.addWidget(self.filter_query_line) + if remove_option: + # add button to remove the row + self.row_layout.addWidget(vertical_line()) + self.row_layout.addWidget(self.remove) + + self.setLayout(self.row_layout) + + # set the state if one was given + if isinstance(state, tuple): + self.set_state(state) + + self.filter_type_box.currentIndexChanged.connect(self.set_input_changes) + self.set_input_changes() + + @property + def get_state(self) -> tuple: + # remove weird whitespace from input + query_line = ( + self.filter_query_line.text() + .translate(str.maketrans("", "", " \n\t\r")) + .strip() + ) + # if valid, return a tuple with the state, otherwise, return None + if query_line == "": + return None + + selected_type = self.filter_type_box.currentText() + selected_query = self.filter_query_line.text() + if self.filter_type_box.currentText() == "<= x <=": + selected_query = ( + self.filter_query_line0.text(), + self.filter_query_line.text(), + ) + return selected_type, selected_query + + def set_state(self, state: tuple) -> None: + selected_type, selected_query = state + self.set_input_changes() + self.filter_type_box.setCurrentIndex(self.filter_type.index(selected_type)) + if selected_type == "<= x <=": + self.filter_query_line0.setText(selected_query[0]) + self.filter_query_line.setText(selected_query[1]) + else: + self.filter_query_line.setText(selected_query) + + def set_input_changes(self) -> None: + # enable whether the extra input line is visible + if self.filter_type_box.currentText() == "<= x <=": + self.filter_query_line0.show() + else: + self.filter_query_line0.hide() + # set tooltip to currently selected item + tt = self.filter_types[self.column_type + "_tt"][ + self.filter_type_box.currentIndex() + ] + self.filter_type_box.setToolTip(tt) + + + +class SimpleFilterDialog(QtWidgets.QDialog): + """Add one filter to a column. + + Related to FilterManagerDialog. + """ + + def __init__( + self, + column_name: dict, + filter_types: dict, + column_type: str = "str", + preset_type: str = None, + parent=None, + ): + super().__init__(parent) + self.setWindowIcon(qicons.filter) + self.setWindowTitle("Add filter") + + # Create filter label and buttons + label = QtWidgets.QLabel("Define a filter for column '{}'".format(column_name)) + + if column_type == "num": + self.filter_row = NumFilterRow( + idx=0, + filter_types=filter_types, + remove_option=False, + preset_type=preset_type, + parent=self, + ) + else: + # if none of the above types, assume str + self.filter_row = StrFilterRow( + idx=0, + filter_types=filter_types, + remove_option=False, + preset_type=preset_type, + parent=self, + ) + + self.filter_row.filter_query_line.setFocus() + + # create OK/cancel buttons + self.buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, + ) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(label) + layout.addWidget(self.filter_row) + layout.addWidget(self.buttons) + self.setLayout(layout) + + @property + def get_filter(self) -> tuple: + if self.filter_row.get_state: + return self.filter_row.get_state + + +class FilterManagerDialog(QtWidgets.QDialog): + """Set filters for a table. + + Dialog has 1 tab per given column. Each tab has rows for filters, + where type/query/other is defined. User can add/remove filters as desired. + When multiple filters exist for 1 column, user can choose AND/OR combination of filters. + AND/OR for combining columns can also be chosen. + + Required inputs: + - column names: dict --> the column names and their indices in the table + format: {'col_name': i} + Optional inputs: + - filters: dict --> pre-apply filters in the dialog (see format example below) + - selected_column: int --> open the dialog with this column tab open + - column_types: dict --> show other filters for this column + format: {'col_name': 'num'} + options: str/num, defaults to str if no type is given + + Interaction: + - call 'start_filter_dialog' of 'ABFilterableDataFrameView' to launch dialog, + filters are only applied when OK is selected. This calls self.get_filters, + which returns filter data as dict. + + example of filters (see also ABMultiColumnSortProxyModel): + filters = { + 0: {'filters': [('contains', 'heat', False), ('contains', 'electricity', False)], + 'mode': 'OR'}, + 1: {'filters': [('contains', 'market', False)]} + } + """ + + def __init__( + self, + column_names: dict, + filter_types: dict, + filters: dict = None, + selected_column: int = 0, + column_types: dict = {}, + parent=None, + ): + super().__init__(parent) + self.setWindowIcon(qicons.filter) + self.setWindowTitle("Manage table filters") + + # set given filters, if any + if isinstance(filters, dict): + self.filters = filters + else: + self.filters = {} + + # create a tab for every column in the table + self.tab_widget = QtWidgets.QTabWidget() + self.tabs = [] + + # we need this dict as we may have hidden columns (e.g. CFTable) + self.col_id_2_tab_id = {} + for tab_id, col_data in enumerate(column_names.items()): + col_name, col_id = col_data + self.col_id_2_tab_id[col_id] = tab_id + tab = ColumnFilterTab( + parent=self, + state=self.filters.get(col_id, None), + col_type=column_types.get(col_name, "str"), + filter_types=filter_types, + ) + self.tabs.append(tab) + self.tab_widget.addTab(tab, col_name) + + # add AND/OR choice button. + self.and_or_buttons = AndOrRadioButtons(label_text="Combine columns:") + # in the extremely unlikely event there is only 1 column, hide the AND/OR option. + if len(column_names) == 1: + self.and_or_buttons.hide() + + # create OK/cancel buttons + self.buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, + ) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + + # assemble layout + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.tab_widget) + layout.addWidget(self.and_or_buttons) + layout.addWidget(self.buttons) + self.setLayout(layout) + + # set the column that launched the dialog as the open tab + self.tab_widget.setCurrentIndex(self.col_id_2_tab_id[selected_column]) + self.tabs[selected_column].filter_rows[-1].filter_query_line.setFocus() + + @property + def get_filters(self) -> dict: + state = {} + t2c = {v: k for k, v in self.col_id_2_tab_id.items()} + for tab_id, tab in enumerate(self.tabs): + tab_state = tab.get_state + if isinstance(tab_state, dict): + state[t2c[tab_id]] = tab_state + if len(state) == 0: + return + state["mode"] = self.and_or_buttons.get_state + return state + + diff --git a/activity_browser/app/pages/lca_results/plots.py b/activity_browser/app/pages/lca_results/plots.py new file mode 100644 index 000000000..a1bc673db --- /dev/null +++ b/activity_browser/app/pages/lca_results/plots.py @@ -0,0 +1,309 @@ +import math +from loguru import logger + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import seaborn as sns + +from bw2data import methods +from activity_browser.ui.widgets import ABPlot +from activity_browser.bwutils.commontasks import wrap_text + + + + + +class LCAResultsBarChart(ABPlot): + """ " Generate a bar chart comparing the absolute LCA scores of the products""" + + def __init__(self, parent=None): + super().__init__(parent) + self.plot_name = "LCA scores" + + def plot(self, df: pd.DataFrame, method: tuple, labels: list): + self.reset_plot() + height_inches, width_inches = self.get_canvas_size_in_inches() + self.figure.set_size_inches(height_inches, width_inches) + + # https://github.com/LCA-ActivityBrowser/activity-browser/issues/489 + df.index = pd.Index(labels) # Replace index of tuples + show_legend = df.shape[1] != 1 # Do not show the legend for 1 column + df.plot.barh(ax=self.ax, legend=show_legend) + self.ax.invert_yaxis() + + # labels + self.ax.set_yticks(np.arange(len(labels))) + self.ax.set_xlabel(methods[method].get("unit")) + self.ax.set_title(", ".join([m for m in method])) + # self.ax.set_yticklabels(labels, minor=False) + + # grid + self.ax.grid(which="major", axis="x", color="grey", linestyle="dashed") + self.ax.set_axisbelow(True) # puts gridlines behind bars + + # draw + self.canvas.draw() + + +class LCAResultsPlot(ABPlot): + def __init__(self, parent=None): + super().__init__(parent) + self.plot_name = "LCA heatmap" + + def plot(self, df: pd.DataFrame, invert_plot: bool = False): + """Plot a heatmap grid of the different impact categories and reference flows.""" + # need to clear the figure and add axis again + # because of the colorbar which does not get removed by the ax.clear() + self.reset_plot() + + dfp = df.copy() + dfp.index = dfp["index"] + dfp.drop( + dfp.select_dtypes(["object"]), axis=1, inplace=True + ) # get rid of all non-numeric columns (metadata) + if "amount" in dfp.columns: + dfp.drop(["amount"], axis=1, inplace=True) # Drop the 'amount' col + if "Score" in dfp.index: + dfp.drop("Score", inplace=True) + + # avoid figures getting too large horizontally + dfp.index = [wrap_text(i, max_length=40) for i in dfp.index] + dfp.columns = [wrap_text(i, max_length=20) for i in dfp.columns] + prop = dfp.divide(dfp.abs().max(axis=0)).multiply(100) + dfp.replace(np.nan, 0, inplace=True) + if invert_plot: + dfp = dfp.T + prop = prop.T + + # set different color palette depending on whether all values are positive or not + if ( + dfp.min(axis=None) < 0 and dfp.max(axis=None) > 0 + ): # has both negative AND positive values + cmap = sns.color_palette("vlag_r", as_cmap=True) + else: # has only positive OR negative values + cmap = sns.color_palette("Blues", as_cmap=True) + + sns.heatmap( + prop, + ax=self.ax, + cmap=cmap, + annot=dfp, + linewidths=0.05, + annot_kws={ + "size": 11 if dfp.shape[1] <= 8 else 9, + "rotation": 0 if dfp.shape[1] <= 8 else 60, + }, + cbar_kws={"format": "%.0f%%"}, + ) + self.ax.tick_params(labelsize=8) + if dfp.shape[1] > 5: + self.ax.set_xticklabels(self.ax.get_xticklabels(), rotation="vertical") + self.ax.set_yticklabels(self.ax.get_yticklabels(), rotation="horizontal") + + # refresh canvas + size_inches = (2 + dfp.shape[0] * 0.5, 4 + dfp.shape[0] * 0.55) + self.figure.set_size_inches(self.get_canvas_size_in_inches()[0], size_inches[1]) + size_pixels = self.figure.get_size_inches() * self.figure.dpi + self.setMinimumHeight(size_pixels[1]) + + self.canvas.draw() + + +class ContributionPlot(ABPlot): + MAX_LEGEND = 30 + + def __init__(self, parent=None): + super().__init__(parent) + self.plot_name = "Contributions" + self.parent = parent + + def plot(self, df: pd.DataFrame, unit: str = None): + """Plot a horizontal stacked bar chart of contributions, + add 'total' marker if both positive and negative results are present.""" + dfp = df.copy() + dfp = dfp.iloc[:, ::-1] # reverse column names so they align with calculation setup and rest of results + + dfp.index = dfp["index"] + dfp.drop( + dfp.select_dtypes(["object"]), axis=1, inplace=True + ) # get rid of all non-numeric columns (metadata) + if "Score" in dfp.index: + dfp.drop("Score", inplace=True) + if "id" in dfp: + dfp.drop(columns=["id"], inplace=True) + # drop rows if all values are 0 except for "Rest (+)" and "Rest (-)" + rows_to_drop = dfp.index[(dfp == 0).all(axis=1) & ~dfp.index.isin(["Rest (+)", "Rest (-)"])] + # Drop those rows + dfp = dfp.drop(rows_to_drop) + + self.ax.clear() + canvas_width_inches, canvas_height_inches = self.get_canvas_size_in_inches() + optimal_height_inches = 4 + dfp.shape[1] * 0.55 + # print('Optimal Contribution plot height:', optimal_height_inches) + self.figure.set_size_inches(canvas_width_inches, optimal_height_inches) + + # avoid figures getting too large horizontally + dfp.index = pd.Index([wrap_text(str(i), max_length=40) for i in dfp.index]) + dfp.columns = pd.Index([wrap_text(i, max_length=40) for i in dfp.columns]) + # Strip invalid characters from the ends of row/column headers + dfp.index = dfp.index.str.strip("_ \n\t") + dfp.columns = dfp.columns.str.strip("_ \n\t") + + # set colormap to use + items = dfp.shape[0] # how many contribution items + # skip grey and black at start/end of cmap + cmap = plt.cm.nipy_spectral_r(np.linspace(0, 1, items + 2))[1:-1] + colors = {item: color for item, color in zip(dfp.index, cmap)} + # overwrite rest values to grey + colors["Rest (+)"] = [0.8, 0.8, 0.8, 1.] + colors["Rest (-)"] = [0.8, 0.8, 0.8, 1.] + + dfp.T.plot.barh( + stacked=True, + color=colors, + ax=self.ax, + legend=False if dfp.shape[0] >= self.MAX_LEGEND else True, + ) + self.ax.tick_params(labelsize=8) + if unit: + self.ax.set_xlabel(unit) + + # show legend if not too many items + if not dfp.shape[0] >= self.MAX_LEGEND: + plt.rc("legend", **{"fontsize": 8}) + ncols = math.ceil(dfp.shape[0] * 0.6 / optimal_height_inches) + # print('Ncols:', ncols, dfp.shape[0] * 0.55, optimal_height_inches) + self.ax.legend(loc="center left", bbox_to_anchor=(1, 0.5), ncol=ncols) + + # grid + self.ax.grid(which="major", axis="x", color="grey", linestyle="dashed") + self.ax.set_axisbelow(True) # puts gridlines behind bars + # make the zero line more present + grid = self.ax.get_xgridlines() + # get the 0 line from all gridlines + label_pos = [i for i, label in enumerate(self.ax.get_xticklabels()) if label.get_position()[0] == 0.0] + if len(label_pos) > 0: + zero_line = grid[label_pos[0]] + zero_line.set_color("black") + zero_line.set_linestyle("solid") + + # total marker when enabled and both negative and positive results are present in a column + if self.parent.score_marker: + marker_size = max(min(150 / dfp.shape[1], 35), 10) # set marker size dynamic between 10 - 35 + for i, col in enumerate(dfp): + total = np.sum(dfp[col]) + abs_total = np.sum(np.abs(dfp[col])) + if abs(total) != abs_total: + self.ax.plot(total, i, + markersize=marker_size, marker="d", fillstyle="left", + markerfacecolor="black", markerfacecoloralt="grey", markeredgecolor="white") + + # TODO review: remove or enable + + # refresh canvas + # size_inches = (2 + dfp.shape[0] * 0.5, 4 + dfp.shape[1] * 0.55) + # self.figure.set_size_inches(self.get_canvas_size_in_inches()[0], size_inches[1]) + + size_pixels = self.figure.get_size_inches() * self.figure.dpi + self.setMinimumHeight(size_pixels[1]) + self.canvas.draw() + + +class CorrelationPlot(ABPlot): + def __init__(self, parent=None): + super().__init__(parent) + sns.set(style="darkgrid") + + def plot(self, df: pd.DataFrame): + """Plot a heatmap of correlations between different reference flows.""" + # need to clear the figure and add axis again + # because of the colorbar which does not get removed by the ax.clear() + self.reset_plot() + canvas_size = self.canvas.get_width_height() + # print("Canvas size:", canvas_size) + size = (4 + df.shape[1] * 0.3, 4 + df.shape[1] * 0.3) + self.figure.set_size_inches(size[0], size[1]) + + corr = df.corr() + # Generate a mask for the upper triangle + mask = np.zeros_like(corr, dtype=bool) + mask[np.triu_indices_from(mask)] = True + # Draw the heatmap with the mask and correct aspect ratio + vmax = np.abs(corr.values[~mask]).max() + # vmax = np.abs(corr).max() + sns.heatmap( + corr, + mask=mask, + cmap=plt.cm.PuOr, + vmin=-vmax, + vmax=vmax, + square=True, + linecolor="lightgray", + linewidths=1, + ax=self.ax, + ) + + df_lte8_cols = df.shape[1] <= 8 + for i in range(len(corr)): + self.ax.text( + i + 0.5, + i + 0.5, + corr.columns[i], + ha="center", + va="center", + rotation=0 if df_lte8_cols else 45, + size=11 if df_lte8_cols else 9, + ) + for j in range(i + 1, len(corr)): + s = "{:.3f}".format(corr.values[i, j]) + self.ax.text( + j + 0.5, + i + 0.5, + s, + ha="center", + va="center", + rotation=0 if df_lte8_cols else 45, + size=11 if df_lte8_cols else 9, + ) + self.ax.axis("off") + + # refresh canvas + size_pixels = self.figure.get_size_inches() * self.figure.dpi + self.setMinimumHeight(size_pixels[1]) + self.canvas.draw() + + +class MonteCarloPlot(ABPlot): + """Monte Carlo plot.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.plot_name = "Monte Carlo" + + def plot(self, df: pd.DataFrame, method: tuple): + self.ax.clear() + + for col in df.columns: + color = self.ax._get_lines.get_next_color() + df[col].hist( + ax=self.ax, + figure=self.figure, + label=col, + density=True, + color=color, + alpha=0.5, + ) # , histtype="step") + # self.ax.axvline(df[col].median(), color=color) + self.ax.axvline(df[col].mean(), color=color) + + self.ax.set_xlabel(methods[method]["unit"]) + self.ax.set_ylabel("Probability") + self.ax.legend( + loc="upper center", + bbox_to_anchor=(0.5, -0.07), + ) # ncol=2 + + # lconfi, upconfi =mc['statistics']['interval'][0], mc['statistics']['interval'][1] + + self.canvas.draw() diff --git a/activity_browser/app/pages/lca_results/sankey_navigator.py b/activity_browser/app/pages/lca_results/sankey_navigator.py new file mode 100644 index 000000000..a71948e4b --- /dev/null +++ b/activity_browser/app/pages/lca_results/sankey_navigator.py @@ -0,0 +1,473 @@ +# -*- coding: utf-8 -*- +import json +import os +import time +from typing import List +from loguru import logger + +import bw2calc as bc +import numpy +from bw_graph_tools.graph_traversal import Edge as GraphEdge +from bw_graph_tools.graph_traversal import NewNodeEachVisitGraphTraversal +from bw_graph_tools.graph_traversal import Node as GraphNode +from qtpy import QtWidgets +from qtpy.QtCore import Slot +from qtpy.QtWidgets import QComboBox + +from activity_browser import app +from activity_browser.mod import bw2data as bd +from bw2data.backends import ActivityDataset + +from activity_browser.bwutils.commontasks import identify_activity_type +from activity_browser.bwutils.filesystem import get_package_path +from activity_browser.ui import widgets + + + +class SankeyNavigatorWidget(widgets.ABAbstractNavigator): + HELP_TEXT = """ + LCA Sankey: + + Red flows: Impacts + Green flows: Avoided impacts + + """ + HTML_FILE = str(get_package_path() / "static" / "sankey_navigator.html") + + def __init__(self, cs_name, parent=None): + super().__init__(parent, css_file="sankey_navigator.css") + + self.cache = {} # we cache the calculated data to improve responsiveness + self.parent = parent + self.has_scenarios = self.parent.has_scenarios + self.cs = cs_name + self.selected_db = None + self.has_sankey = False + self.func_units = [] + self.methods = [] + self.scenarios = [] + self.graph = Graph() + + # Additional Qt objects + self.scenario_label = QtWidgets.QLabel("Scenario: ") + self.func_unit_cb = QtWidgets.QComboBox() + self.method_cb = QtWidgets.QComboBox() + self.scenario_cb = QtWidgets.QComboBox() + self.cutoff_sb = QtWidgets.QDoubleSpinBox() + self.max_calc_sb = QtWidgets.QDoubleSpinBox() + self.button_calculate = QtWidgets.QPushButton("Calculate") + self.layout = QtWidgets.QVBoxLayout() + + # graph + self.draw_graph() + self.construct_layout() + self.connect_signals() + + @Slot(name="loadFinishedHandler") + def load_finished_handler(self) -> None: + if self.has_sankey: + self.send_json() + + def connect_signals(self): + super().connect_signals() + self.button_calculate.clicked.connect(self.new_sankey) + app.signals.database_selected.connect(self.set_database) + # checkboxes + self.func_unit_cb.currentIndexChanged.connect(self.new_sankey) + self.method_cb.currentIndexChanged.connect(self.new_sankey) + self.scenario_cb.currentIndexChanged.connect(self.new_sankey) + + def construct_layout(self) -> None: + """Layout of Sankey Navigator""" + super().construct_layout() + self.label_help.setVisible(False) + + # Layout Reference Flows and Impact Categories + grid_lay = QtWidgets.QGridLayout() + grid_lay.addWidget(QtWidgets.QLabel("Reference flow: "), 0, 0) + + grid_lay.addWidget(self.scenario_label, 1, 0) + grid_lay.addWidget(QtWidgets.QLabel("Impact indicator: "), 2, 0) + + self.update_calculation_setup() + + grid_lay.addWidget(self.func_unit_cb, 0, 1) + grid_lay.addWidget(self.scenario_cb, 1, 1) + grid_lay.addWidget(self.method_cb, 2, 1) + + # cut-off + grid_lay.addWidget(QtWidgets.QLabel("Cutoff: "), 2, 2) + self.cutoff_sb.setRange(0.0, 1.0) + self.cutoff_sb.setSingleStep(0.01) + self.cutoff_sb.setDecimals(3) + self.cutoff_sb.setValue(0.05) + self.cutoff_sb.setKeyboardTracking(False) + grid_lay.addWidget(self.cutoff_sb, 2, 3) + + # max-iterations of graph traversal + grid_lay.addWidget(QtWidgets.QLabel("Calculation depth: "), 2, 4) + self.max_calc_sb.setRange(1, 2000) + self.max_calc_sb.setSingleStep(50) + self.max_calc_sb.setDecimals(0) + self.max_calc_sb.setValue(250) + self.max_calc_sb.setKeyboardTracking(False) + grid_lay.addWidget(self.max_calc_sb, 2, 5) + + grid_lay.setColumnStretch(6, 1) + hlay = QtWidgets.QHBoxLayout() + hlay.addLayout(grid_lay) + + # Controls Layout + hl_controls = QtWidgets.QHBoxLayout() + hl_controls.addWidget(self.button_back) + hl_controls.addWidget(self.button_forward) + hl_controls.addWidget(self.button_calculate) + hl_controls.addWidget(self.button_refresh) + hl_controls.addWidget(self.button_random_activity) + hl_controls.addWidget(self.button_toggle_help) + hl_controls.addStretch(1) + + # Layout + self.layout.addLayout(hl_controls) + self.layout.addLayout(hlay) + self.layout.addWidget(self.label_help) + self.layout.addWidget(self.view) + self.setLayout(self.layout) + + def get_scenario_labels(self) -> List[str]: + """Get scenario labels if scenario is used.""" + return self.parent.mlca.scenario_names if self.has_scenarios else [] + + def configure_scenario(self): + """Determine if scenario Qt widgets are visible or not and retrieve + scenario labels for the selection drop-down box. + """ + self.scenario_cb.setVisible(self.has_scenarios) + self.scenario_label.setVisible(self.has_scenarios) + if self.has_scenarios: + self.scenarios = self.get_scenario_labels() + self.update_combobox(self.scenario_cb, self.scenarios) + + @staticmethod + def update_combobox(box: QComboBox, labels: List[str]) -> None: + """Update the combobox menu.""" + box.blockSignals(True) + box.clear() + box.insertItems(0, labels) + box.blockSignals(False) + + def update_calculation_setup(self, cs_name=None) -> None: + """Update Calculation Setup, reference flows and impact categories, and dropdown menus.""" + # block signals + self.func_unit_cb.blockSignals(True) + self.method_cb.blockSignals(True) + + self.cs = cs_name or self.cs + self.func_units = [ + {bd.get_activity(k): v for k, v in fu.items()} + for fu in bd.calculation_setups[self.cs]["inv"] + ] + self.methods = bd.calculation_setups[self.cs]["ia"] + self.func_unit_cb.clear() + fu_acts = [list(fu.keys())[0] for fu in self.func_units] + self.func_unit_cb.addItems( + [f"{repr(a)} | {a._data.get('database')}" for a in fu_acts] + ) + self.configure_scenario() + self.method_cb.clear() + self.method_cb.addItems([repr(m) for m in self.methods]) + + # unblock signals + self.func_unit_cb.blockSignals(False) + self.method_cb.blockSignals(False) + + def new_sankey(self) -> None: + """(re)-generate the sankey diagram.""" + demand_index = self.func_unit_cb.currentIndex() + method_index = self.method_cb.currentIndex() + + demand = self.func_units[demand_index] + method = self.methods[method_index] + scenario_index = None + scenario_lca = False + if self.has_scenarios: + scenario_lca = True + scenario_index = self.scenario_cb.currentIndex() + self.update_sankey( + demand, + method, + demand_index=demand_index, + method_index=method_index, + scenario_index=scenario_index, + scenario_lca=scenario_lca, + cut_off=self.cutoff_sb.value(), + max_calc=int(self.max_calc_sb.value()), + ) + + def update_sankey( + self, + demand: dict, + method: tuple, + demand_index: int = None, + method_index: int = None, + scenario_index: int = None, + scenario_lca: bool = False, + cut_off=0.05, + max_calc=100, + ) -> None: + """Calculate LCA, do graph traversal, get JSON graph data for this, and send to javascript.""" + + # the cache key consists of demand/method/scenario indices (index of item in the relevant tables), + # the cutoff, max_calc. + # together, these are unique. + cache_key = (demand_index, method_index, scenario_index, cut_off, max_calc) + if data := self.cache.get(cache_key, False): + # this Sankey is already cached, generate the Sankey with the cached data + logger.debug(f"CACHED sankey for: {demand}, {method}, key: {cache_key}") + self.graph.new_graph(data) + self.has_sankey = bool(self.graph.json_data) + self.send_json() + return + + start = time.time() + logger.debug(f"CALCULATE sankey for: {demand}, {method}, key: {cache_key}") + try: + if scenario_lca: + self.parent.mlca.update_lca_calculation_for_sankey( + scenario_index, demand, method_index + ) + lca = self.parent.mlca.lca + data = NewNodeEachVisitGraphTraversal.calculate( + lca, cutoff=cut_off, max_calc=int(max_calc) + ) + else: + fu, data_objs, _ = bd.prepare_lca_inputs(demand=demand, method=method) + lca = bc.LCA(demand=fu, data_objs=data_objs) + lca.lci(factorize=True) + lca.lcia() + data = NewNodeEachVisitGraphTraversal.calculate( + lca_object=lca, cutoff=cut_off, max_calc=int(max_calc) + ) + + # store the metadata from this calculation + data["metadata"] = { + "lca": lca, + "unit": bd.methods[method]["unit"], + } + except (ValueError, ZeroDivisionError) as e: + QtWidgets.QMessageBox.information( + None, "Nonsensical numeric result.", str(e) + ) + logger.debug(f"Completed graph traversal ({round(time.time() - start, 2)} seconds") + + # cache the generated Sankey data + self.cache[cache_key] = data + + # generate the new Sankey + self.graph.new_graph(data) + self.has_sankey = bool(self.graph.json_data) + self.send_json() + + def set_database(self, name): + """Saves the currently selected database for graphing a random activity""" + self.selected_db = name + + def random_graph(self) -> None: + """Show graph for a random activity in the currently loaded database.""" + if self.selected_db: + method = bd.methods.random() + act = bd.Database(self.selected_db).random() + demand = {act: 1.0} + self.update_sankey(demand, method) + else: + QtWidgets.QMessageBox.information( + None, "Not possible.", "Please load a database first." + ) + + +def convert_numpy_types(obj) -> int | float | list: + """Converts numpy types into serializable types""" + if isinstance(obj, numpy.integer): + return int(obj) + if isinstance(obj, numpy.floating): + return float(obj) + if isinstance(obj, numpy.ndarray): + return obj.tolist() + return obj + + +def make_serializable(data: dict) -> dict: + """Converts numpy data into serializable values for json.dumps""" + for key, value in data.items(): + if isinstance(value, dict): + make_serializable(value) + elif isinstance(value, list): + data[key] = [ + ( + convert_numpy_types(v) + if not isinstance(v, dict) + else make_serializable(v) + ) + for v in value + ] + else: + data[key] = convert_numpy_types(value) + return data + + +class Graph(widgets.ABAbstractGraph): + """ + Python side representation of the graph. + Functionality for graph navigation (e.g. adding and removing nodes). + A JSON representation of the graph (edges and nodes) enables its use in javascript/html/css. + """ + + def new_graph(self, data): + self.json_data = Graph.get_json_data(data) + self.update() + + @staticmethod + def get_json_data(data) -> str: + """Transform graph traversal output to JSON data. + + We use the [dagre](https://github.com/dagrejs/dagre) javascript library for rendering directed graphs. We need to provide the following: + + ```python + { + 'max_impact': float, # Total LCA score, + 'title': str, # Graph title + 'edges': [{ + 'source_id': int, # Unique ID of producer of material or energy in graph + 'target_id': int, # Unique ID of consumer of material or energy in graph + 'weight': float, # In graph units, relative to `max_edge_width` + 'label': str, # HTML label + 'product': str, # The label of the flowing material or energy + 'class': str, # "benefit" or "impact"; controls styling + 'label': str, # HTML label + 'toottip': str, # HTML tooltip + }], + 'nodes': [{ + 'direct_emissions_score_normalized': float, # Fraction of total LCA score from direct emissions + 'product': str, # Reference product label, if any + 'location': str, # Location, if any + 'id': int, # Graph traversal ID + 'database_id': int, # Node ID in SQLite database + 'database': str, # Database name + 'class': str, # Enumerated set of class label strings + 'label': str, # HTML label including name and location + 'toottip': str, # HTML tooltip + }], + } + + ``` + + """ + lca_score = data["metadata"]["lca"].score + lcia_unit = data["metadata"]["unit"] + demand = data["metadata"]["lca"].demand + + def convert_edge_to_json( + edge: GraphEdge, + nodes: dict[int, GraphNode], + total_score: float, + lcia_unit: str, + max_edge_width: int = 40, + ) -> dict: + cum_score = nodes[edge.producer_unique_id].cumulative_score + unit = bd.get_node( + id=nodes[edge.producer_unique_id].reference_product_datapackage_id + ).get("unit", "(unknown)") + return { + "source_id": edge.producer_unique_id, + "target_id": edge.consumer_unique_id, + "amount": edge.amount, + "weight": abs(cum_score / total_score) * max_edge_width, + "label": f"{round(cum_score, 3)} {lcia_unit}", + "class": "benefit" if cum_score < 0 else "impact", + "tooltip": f"{round(cum_score, 3)} {lcia_unit} ({edge.amount:.2g} {unit})", + } + + def convert_node_to_json( + graph_node: GraphNode, + total_score: float, + fu: dict, + lcia_unit: str, + max_name_length: int = 20, + ) -> dict: + db_node = bd.get_node(id=graph_node.activity_datapackage_id) + data = { + "direct_emissions_score_normalized": graph_node.direct_emissions_score + / (total_score or 1), + "direct_emissions_score": graph_node.direct_emissions_score, + "cumulative_score": graph_node.cumulative_score, + "cumulative_score_normalized": graph_node.cumulative_score + / (total_score or 1), + "product": db_node.get("reference product", ""), + "location": db_node.get("location", "(unknown)"), + "id": graph_node.unique_id, + "database_id": graph_node.activity_datapackage_id, + "database": db_node["database"], + "class": ( + "demand" + if graph_node.activity_datapackage_id in fu + else identify_activity_type(db_node) + ), + "name": db_node.get("name", "(unnamed)"), + } + frac_dir_score = round(data["direct_emissions_score_normalized"] * 100, 2) + dir_score = round(data["direct_emissions_score"], 3) + frac_cum_score = round(data["cumulative_score_normalized"] * 100, 2) + cum_score = round(data["cumulative_score"], 3) + data[ + "label" + ] = f"""{db_node['name'][:max_name_length]} +{data['location']} +{frac_dir_score}%""" + data[ + "tooltip" + ] = f""" + {data['name']} +
Individual impact: {dir_score} {lcia_unit} ({frac_dir_score }%) +
Cumulative impact: {cum_score} {lcia_unit} ({frac_cum_score}%) + """ + return data + + json_data = { + "nodes": [ + convert_node_to_json(node, lca_score, demand, lcia_unit) + for idx, node in data["nodes"].items() + if idx != -1 + ], + "edges": [ + convert_edge_to_json(edge, data["nodes"], lca_score, lcia_unit) + for edge in data["edges"] + if edge.producer_index != -1 and edge.consumer_index != -1 + ], + "title": "Sankey graph result", + # "title": self.build_title(demand, lca_score, lcia_unit), + } + + return json.dumps(json_data) + + def build_title(self, demand: tuple, lca_score: float, lcia_unit: str) -> str: + act, amount = demand[0], demand[1] + if type(act) is tuple or type(act) is int: + act = bd.get_activity(act) + format_str = ( + "Reference flow: {:.2g} {} {} | {} | {}
" "Total impact: {:.2g} {}" + ) + return format_str.format( + amount, + act.get("unit"), + act.get("reference product") or act.get("name"), + act.get("name"), + act.get("location"), + lca_score, + lcia_unit, + ) + + +def id_to_key(id): + if isinstance(id, tuple): + return id + return ActivityDataset.get_by_id(id).database, ActivityDataset.get_by_id(id).code diff --git a/activity_browser/app/pages/lca_results/style.py b/activity_browser/app/pages/lca_results/style.py new file mode 100644 index 000000000..29d749cf3 --- /dev/null +++ b/activity_browser/app/pages/lca_results/style.py @@ -0,0 +1,25 @@ +from qtpy import QtWidgets, QtGui + +def horizontal_line(): + line = QtWidgets.QFrame() + line.setFrameShape(QtWidgets.QFrame.HLine) + line.setFrameShadow(QtWidgets.QFrame.Sunken) + return line + + +def vertical_line(): + line = QtWidgets.QFrame() + line.setFrameShape(QtWidgets.QFrame.VLine) + line.setFrameShadow(QtWidgets.QFrame.Sunken) + return line + + +def header(text): + label = QtWidgets.QLabel(text) + + bold_font = QtGui.QFont() + bold_font.setBold(True) + bold_font.setPointSize(12) + + label.setFont(bold_font) + return label \ No newline at end of file diff --git a/activity_browser/app/pages/lca_results/tables.py b/activity_browser/app/pages/lca_results/tables.py new file mode 100644 index 000000000..a44677581 --- /dev/null +++ b/activity_browser/app/pages/lca_results/tables.py @@ -0,0 +1,989 @@ +import os +import datetime +from typing import Optional, Any +from loguru import logger + +import arrow +import numpy as np +import bw2data as bd +import pandas as pd + +from qtpy import QtGui, QtWidgets, QtCore +from qtpy.QtCore import QPoint, QRect, QSize, Qt, QTimer, Signal, Slot, SignalInstance +from qtpy.QtWidgets import QSizePolicy, QTableView + +from activity_browser.ui.icons import qicons +from activity_browser.ui import delegates +from activity_browser.bwutils import filesystem + +from .dialogs import FilterManagerDialog, SimpleFilterDialog + + + + + +class CustomHeader(QtWidgets.QHeaderView): + """Header which has a filter button on each cell that can trigger a signal. + + Largely based on https://stackoverflow.com/a/30938728 + """ + + clicked: SignalInstance = Signal(int, str) + + _x_offset = 0 + _y_offset = ( + 0 # This value is calculated later, based on the height of the paint rect + ) + _width = 18 + _height = 18 + + def __init__(self, orientation=Qt.Horizontal, parent=None): + super(CustomHeader, self).__init__(orientation, parent) + self.setSectionsClickable(True) + + self.column_indices = [] + self.has_active_filters = [] # list of column indices that have filters active + self.event_pos = None + + def paintSection(self, painter, rect, logical_index): + """Paint the button onto the column header.""" + painter.save() + super(CustomHeader, self).paintSection(painter, rect, logical_index) + painter.restore() + + self._y_offset = int(rect.height() - self._width) + + if logical_index in self.column_indices: + option = QtWidgets.QStyleOptionButton() + option.rect = QRect( + rect.x() + self._x_offset, + rect.y() + self._y_offset, + self._width, + self._height, + ) + option.state = ( + QtWidgets.QStyle.State_Enabled | QtWidgets.QStyle.State_Active + ) + + # put the filter icon onto the label + if logical_index in self.has_active_filters: + option.icon = qicons.filter + else: + option.icon = qicons.filter_outline + option.iconSize = QSize(16, 16) + + # set the settings to a PushButton + self.style().drawControl(QtWidgets.QStyle.CE_PushButton, option, painter) + + def mousePressEvent(self, event): + index = self.logicalIndexAt(event.pos()) + if index in self.column_indices: + x = self.sectionPosition(index) + if ( + x + self._x_offset < event.pos().x() < x + self._x_offset + self._width + and self._y_offset < event.pos().y() < self._y_offset + self._height + ): + # the button is clicked + + # set the position of the lower left point of the filter button to spawn a menu + pos = QPoint() + pos.setX(x + self._x_offset + self._width) + pos.setY(self._y_offset + self._height) + self.event_pos = pos + + # emit the column index and the button (left/right) pressed + self.clicked.emit(index, str(event.button()).split(".")[-1]) + else: + # pass the event to the header (for sorting) + super(CustomHeader, self).mousePressEvent(event) + else: + # pass the event to the header (for sorting) + super(CustomHeader, self).mousePressEvent(event) + self.viewport().update() + + +class PandasModel(QtCore.QAbstractTableModel): + """Abstract pandas table model adapted from + https://stackoverflow.com/a/42955764. + """ + + HEADERS = [] + updated: SignalInstance = Signal() + + def __init__(self, df: pd.DataFrame = None, parent=None): + super().__init__(parent) + self._dataframe: Optional[pd.DataFrame] = df + self.filterable_columns = None + self.different_column_types = {} + # The list of columns which should be editable by the builtin checkbox editor + # The value of the dict holds whether the value should also be displayed as text + self._checkbox_editors: dict[int, tuple[bool, Any, Any]] = {} + self._columns: list[str] = [] + + @property + def columns(self) -> list[str]: + if self._dataframe is not None: + return self._dataframe.columns + return [] + + def rowCount(self, parent=None, *args, **kwargs): + return 0 if self._dataframe is None else self._dataframe.shape[0] + + def columnCount(self, parent=None, *args, **kwargs): + return 0 if self._dataframe is None else self._dataframe.shape[1] + + def data(self, index, role=Qt.DisplayRole): + """ + Return value for table index based on a certain DisplayRole enum. + + More on DisplayRole enums: https://doc.qt.io/qt-5/qt.html#ItemDataRole-enum + """ + if not index.isValid(): + return None + # instantiate value only in case of DisplayRole or ToolTipRole + value = None + tt_date_flag = False # flag to indicate if value is datetime object and role is ToolTipRole + if role in [Qt.DisplayRole, Qt.ToolTipRole, "sorting", Qt.EditRole]: + value = self._dataframe.iat[index.row(), index.column()] + if isinstance(value, np.float64): + value = float(value) + elif isinstance(value, bool): + value = str(value) + elif isinstance(value, np.int64): + value = value.item() + elif isinstance(value, tuple): + value = str(value) + elif isinstance(value, datetime.datetime) and ( + Qt.DisplayRole or Qt.ToolTipRole + ): + tz = datetime.datetime.now(datetime.timezone.utc).astimezone() + time_shift = -tz.utcoffset().total_seconds() + if role == Qt.ToolTipRole: + value = ( + arrow.get(value) + .shift(seconds=time_shift) + .format("YYYY-MM-DD HH:mm:ss") + ) + tt_date_flag = True + elif role == Qt.DisplayRole: + value = arrow.get(value).shift(seconds=time_shift).humanize() + + # Handle checkbox editors + # Checkbox editors can return two values for one cell: the usual display value + # and a checked / not checked enum. It is useful to return both, when the + # underlying data is not bool, but text to visualize eventual errors. + if index.column() in self._checkbox_editors: + if role == Qt.ItemDataRole.CheckStateRole: + value = self._dataframe.iat[index.row(), index.column()] + if isinstance(value, str): + logger.error(f"Expected bool, received str: {value}!!") + true_value = self._checkbox_editors[index.column()][1] + # Convert the data to an appropriate value for the checkbox + return Qt.CheckState.Checked if value == true_value else Qt.CheckState.Unchecked + display_value = self._checkbox_editors[index.column()][0] + if role == Qt.ItemDataRole.DisplayRole and not display_value: + return None + + # immediately return value in case of DisplayRole or sorting + if role == Qt.DisplayRole or role == "sorting": + return value + + # in case of ToolTipRole and date, always show the full date + if tt_date_flag and role == Qt.ToolTipRole: + return value + + # in case of ToolTipRole, check whether content fits the cell + if role == Qt.ToolTipRole: + parent = self.parent() + fontMetrics = parent.fontMetrics() + + # get the width of both the cell, and the text + column_width = parent.columnWidth(index.column()) + text_width = fontMetrics.horizontalAdvance(str(value)) + margin = 10 + + # only show tooltip if the text is wider then the cell minus the margin + if text_width > column_width - margin: + return value + + return None + + def flags(self, index): + return Qt.ItemIsSelectable | Qt.ItemIsEnabled + + def headerData(self, section, orientation, role=Qt.DisplayRole): + if orientation == Qt.Horizontal and role == Qt.DisplayRole: + return self._dataframe.columns[section] + elif orientation == Qt.Vertical and role == Qt.DisplayRole: + return self._dataframe.index[section] + return None + + def row_data(self, index: int) -> list: + """Return the row at index as a list.""" + return self._dataframe.iloc[index, :].tolist() + + def to_clipboard(self, rows, columns, include_header: bool = False): + """Copy the given rows and columns of the dataframe to clipboard""" + self._dataframe.iloc[rows, columns].to_clipboard( + index=False, header=include_header + ) + + def to_csv(self, path: str) -> None: + """Store the dataframe as csv in the given path.""" + self._dataframe.to_csv(path) + + def to_excel(self, path: str) -> None: + """Store the underlying dataframe as excel in the given path""" + self._dataframe.to_excel(excel_writer=path) + + def sync(self, *args, **kwargs) -> None: + """(Re)build the dataframe according to the given arguments.""" + self._dataframe = pd.DataFrame([], columns=self.HEADERS) + + @staticmethod + def proxy_to_source(proxy: QtCore.QModelIndex) -> QtCore.QModelIndex: + """Step from the QSortFilterProxyModel to the underlying PandasModel.""" + model = proxy.model() + if not hasattr(model, "mapToSource"): + return proxy # Proxy is actually the PandasModel + return model.mapToSource(proxy) + + @staticmethod + def test_query_on_column(test_type: str, col_data: pd.Series, query) -> bool: + """Compare query and col_data on test_type, return array with boolean test results.""" + if test_type == "equals": + return col_data == query + elif test_type == "does not equal": + return col_data != query + elif test_type == "contains": + return col_data.str.contains(query, regex=False) + elif test_type == "does not contain": + return ~col_data.str.contains(query, regex=False) + elif test_type == "starts with": + return col_data.str.startswith(query) + elif test_type == "does not start with": + return ~col_data.str.startswith(query) + elif test_type == "ends with": + return col_data.str.endswith(query) + elif test_type == "does not end with": + return ~col_data.str.endswith(query) + elif test_type == "=": + return col_data.astype(float) == float(query) + elif test_type == "!=": + return col_data.astype(float) != float(query) + elif test_type == ">=": + return col_data.astype(float) >= float(query) + elif test_type == "<=": + return col_data.astype(float) <= float(query) + elif test_type == "<= x <=": + return (float(query[0]) <= col_data.astype(float)) & ( + col_data.astype(float) <= float(query[1]) + ) + else: + logger.warning("unknown filter type >{}<, assuming 'EQUALS'".format(test_type)) + return col_data == query + + def get_filter_mask(self, filters: dict) -> pd.Series: + """Generate a filter mask of the dataframe based on the filters. + + Returns a pd.Series of boolean results (the mask). + """ + # get the column name from index + fc_rev = {v: k for k, v in self.filterable_columns.items()} + + all_mode = filters["mode"] + all_mask = None + # iterate over columns + for col_idx, col_filters in filters.items(): + if col_idx == "mode": + continue + col_name = fc_rev[col_idx] + col_data = self._dataframe[col_name] + col_mode = col_filters.get("mode", False) + col_mask = None + # iterate over filters within column + for col_filt in col_filters["filters"]: + if self.different_column_types.get(col_name, False): + # this is a 'num' column + filt_type, query = col_filt + col_data_ = col_data + else: + # this is a 'str' column + filt_type, query, case_sensitive = col_filt + if case_sensitive: + col_data_ = col_data.astype(str) + else: + col_data_ = col_data.astype(str).str.upper() + query = query.upper() + + # run the test + new_mask = self.test_query_on_column(filt_type, col_data_, query) + if not any(new_mask): + # no matches for this mask, let user know: + logger.info( + "There were no matches for filter: {}: '{}'".format( + col_filt[0], col_filt[1] + ) + ) + + # create or combine new mask within column + if isinstance(col_mask, pd.Series) and col_mode == "AND": + col_mask = col_mask & new_mask + elif isinstance(col_mask, pd.Series) and col_mode == "OR": + col_mask = col_mask + new_mask + else: + col_mask = new_mask + + # create or combine new mask on columns + if isinstance(all_mask, pd.Series) and all_mode == "AND": + all_mask = all_mask & col_mask + elif isinstance(all_mask, pd.Series) and all_mode == "OR": + all_mask = all_mask + col_mask + else: + all_mask = col_mask + return all_mask + + def set_read_only(self, read_only: bool): + """Interface function, to support editable models""" + pass + + def is_read_only(self) -> bool: + """Interface function, to support editable models""" + return True + + def set_builtin_checkbox_delegate(self, column: int, show_text_value: bool, + true_value: Any = True, false_value: Any = False): + """ + Enables the builtin checkbox delegate for columns. + Can be used on bool values only. + As the underlying data can be bool or string, we provide the values to be + stored as parameters. + """ + self._checkbox_editors[column] = (show_text_value, true_value, false_value) + + +class ABSortProxyModel(QtCore.QSortFilterProxyModel): + """Reimplementation to allow for sorting on the actual data in cells instead of the visible data. + + See this for context: https://github.com/LCA-ActivityBrowser/activity-browser/pull/1151 + """ + + def lessThan(self, left: QtCore.QModelIndex, right: QtCore.QModelIndex) -> bool: + """Override to sort actual data, expects `left` and `right` are comparable. + + If `left` and `right` are not the same type, we check if numerical and empty string are compared, if that is the + case, we assume empty string == 0. + Added this case for: https://github.com/LCA-ActivityBrowser/activity-browser/issues/1215 + """ + left_data = self.sourceModel().data(left, "sorting") + right_data = self.sourceModel().data(right, "sorting") + + if not left_data and not right_data: + return True + if type(left_data) is type(right_data): + return left_data < right_data + + # comparing Falsys with types + if (isinstance(left_data, (int, float)) + and not right_data + ): # comparing left number with nothing, compare against '0' instead + return left_data < 0 + if (isinstance(left_data, str) + and not right_data + ): # comparing left str with nothing, compare against "" instead + return left_data < "" # note we use '>' instead of '<', content should be above empty fields + if (isinstance(right_data, (int, float)) + and not left_data + ): # comparing right number with nothing, compare against '0' instead + return 0 < right_data + if (isinstance(right_data, str) + and not left_data + ): # comparing right str with nothing, compare against "" instead + return right_data < "" # note we use '>' instead of '<', content should be above empty fields + + raise ValueError( + f"Cannot compare {left_data} and {right_data}, incompatible types." + ) + + +class ABMultiColumnSortProxyModel(ABSortProxyModel): + """Subclass of QSortFilterProxyModel to enable sorting on multiple columns. + + The main purpose of this subclass is to override def filterAcceptsRow(). + + Subclass based on various ideas from: + https://stackoverflow.com/questions/47201539/how-to-filter-multiple-column-in-qtableview + http://www.dayofthenewdan.com/2013/02/09/Qt_QSortFilterProxyModel.html + https://gist.github.com/dbridges/4732790 + """ + + def __init__(self, parent=None): + super(ABMultiColumnSortProxyModel, self).__init__(parent) + + # the filter mask, an iterable array with boolean values on whether or not to keep the row + self.mask = None + + # metric to keep track of successful matches on filter + self.matches = 0 + + # custom filter activation + self.activate_filter = False + + def set_filters(self, mask) -> None: + self.mask = mask + self.matches = 0 + self.activate_filter = True + self.invalidateFilter() + self.activate_filter = False + logger.info("{} filter matches found".format(self.matches)) + + def clear_filters(self) -> None: + self.mask = None + self.invalidateFilter() + + def filterAcceptsRow(self, row: int, parent) -> bool: + # check if self.activate_filter is enabled, else return True + if not self.activate_filter: + return True + # get the right index from the mask + matched = self.mask.iloc[row] + if matched: + self.matches += 1 + return matched + + +class ABDataFrameView(QtWidgets.QTableView): + """Base class for showing pandas dataframe objects as tables.""" + + ALL_FILTER = "All Files (*.*)" + CSV_FILTER = "CSV (*.csv);; All Files (*.*)" + TSV_FILTER = "TSV (*.tsv);; All Files (*.*)" + EXCEL_FILTER = "Excel (*.xlsx);; All Files (*.*)" + + def __init__(self, parent=None): + super().__init__(parent) + self.setVerticalScrollMode(QTableView.ScrollPerPixel) + self.setHorizontalScrollMode(QTableView.ScrollPerPixel) + + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + + self.setWordWrap(True) + self.setAlternatingRowColors(True) + self.setSortingEnabled(True) + + self.horizontalHeader().setStretchLastSection(True) + self.horizontalHeader().setHighlightSections(False) + self.horizontalHeader().setDefaultAlignment(Qt.AlignLeft) + + self.verticalHeader().setDefaultSectionSize(22) # row height + self.verticalHeader().setVisible(False) + # Use a custom ViewOnly delegate by default. + # Can be overridden table-wide or per column in child classes. + self.setItemDelegate(delegates.ViewOnlyDelegate(self)) + + self.table_name = "LCA results" + # Initialize attributes which are set during the `sync` step. + # Creating (and typing) them here allows PyCharm to see them as + # valid attributes. + self.model: Optional[PandasModel] = None + self.proxy_model: Optional[ABSortProxyModel] = None + + def rowCount(self) -> int: + return 0 if self.model is None else self.model.rowCount() + + @Slot(name="updateProxyModel") + def update_proxy_model(self) -> None: + self.proxy_model = ABSortProxyModel(self) + self.proxy_model.setSourceModel(self.model) + self.proxy_model.setSortCaseSensitivity(Qt.CaseInsensitive) + self.setModel(self.proxy_model) + + @Slot(name="exportToClipboard") + def to_clipboard(self): + """Copy dataframe to clipboard""" + rows = list(range(self.model.rowCount())) + cols = list(range(self.model.columnCount())) + self.model.to_clipboard(rows, cols, include_header=True) + + def savefilepath( + self, default_file_name: str, caption: str = None, file_filter: str = None + ): + """Construct and return default path where data is stored + + Uses the application directory for AB + """ + safe_name = bd.utils.safe_filename(default_file_name, add_hash=False) + caption = caption or "Choose location to save lca results" + filepath, _ = QtWidgets.QFileDialog.getSaveFileName( + parent=self, + caption=caption, + dir=str(os.path.join(filesystem.get_project_path(), safe_name)), + filter=file_filter or self.ALL_FILTER, + ) + # getSaveFileName can now weirdly return Path objects. + return str(filepath) if filepath else filepath + + @Slot(name="exportToCsv") + def to_csv(self): + """Save the dataframe data to a CSV file.""" + filepath = self.savefilepath(self.table_name, file_filter=self.CSV_FILTER) + if filepath: + if not filepath.endswith(".csv"): + filepath += ".csv" + self.model.to_csv(filepath) + + @Slot(name="exportToExcel") + def to_excel(self, caption: str = None): + """Save the dataframe data to an excel file.""" + filepath = self.savefilepath( + self.table_name, caption, file_filter=self.EXCEL_FILTER + ) + if filepath: + if not filepath.endswith(".xlsx"): + filepath += ".xlsx" + self.model.to_excel(filepath) + + @Slot(QtGui.QKeyEvent, name="copyEvent") + def keyPressEvent(self, e): + """Allow user to copy selected data from the table + + NOTE: by default, the table headers (column names) are also copied. + """ + if e.modifiers() & Qt.ControlModifier: + # Should we include headers? + headers = e.modifiers() & Qt.ShiftModifier + if e.key() == Qt.Key_C: # copy + selection = [ + self.model.proxy_to_source(p) for p in self.selectedIndexes() + ] + rows = [index.row() for index in selection] + columns = [index.column() for index in selection] + rows = sorted(set(rows), key=rows.index) + columns = sorted(set(columns), key=columns.index) + self.model.to_clipboard(rows, columns, headers) + + +class ABFilterableDataFrameView(ABDataFrameView): + """Filterable base class for showing pandas dataframe objects as tables. + + To use this table, the following MUST be set in the table model: + - self.filterable_columns: dict + --> these columns are available for filtering + --> key is column name, value is column index + + To use this table, the following MUST be set in the table view: + - self.header.column_indices = list(self.model.filterable_columns.values()) + --> If not set, no filter buttons will appear. + --> Probably wise to set in a `if isinstance(self.model.filterable_columns, dict):` + --> This variable must be set any time the columns of the table change + + To use this table, the following can be set in the table model: + - self.different_column_types: dict + --> these columns require a different filter type than 'str' + --> e.g. self.different_column_types = {'col_name': 'num'} + """ + + FILTER_TYPES = { + "str": [ + "contains", + "does not contain", + "equals", + "does not equal", + "starts with", + "does not start with", + "ends with", + "does not end with", + ], + "str_tt": [ + "values in the column contain", + "values in the column do not contain", + "values in the column equal", + "values in the column do not equal", + "values in the column start with", + "values in the column do not start with", + "values in the column end with", + "values in the column do not end with", + ], + "num": ["=", "!=", ">=", "<=", "<= x <="], + "num_tt": [ + "values in the column equal", + "values in the column do not equal", + "values in the column are greater than or equal to", + "values in the column are smaller than or equal to", + "values in the column are between", + ], + } + + def __init__(self, parent=None): + super().__init__(parent) + + self.header = CustomHeader() + self.setHorizontalHeader(self.header) + + self.filters = None + self.different_column_types = {} + self.header.clicked.connect(self.header_filter_button_clicked) + self.selected_column = 0 + + # quick-filter setup: + self.prev_quick_filter = {} + self.debounce_quick_filter = QTimer() + self.debounce_quick_filter.setInterval(300) + self.debounce_quick_filter.setSingleShot(True) + self.debounce_quick_filter.timeout.connect(self.quick_filter) + + def header_filter_button_clicked(self, column: int, button: str) -> None: + self.selected_column = column + # this function is separate from the context menu in case we want to add right-click options later + if button == "LeftButton": + self.header_context_menu() + + def header_context_menu(self) -> None: + menu = QtWidgets.QMenu(self) + menu.setToolTipsVisible(True) + + col_type = self.model.different_column_types.get( + {v: k for k, v in self.model.filterable_columns.items()}[ + self.selected_column + ], + "str", + ) + + # quick-filter bar + self.input_line = QtWidgets.QLineEdit() + self.input_line.setFocusPolicy(Qt.StrongFocus) + if col_type == "num": + self.input_line.setValidator(QtGui.QDoubleValidator()) + search = QtWidgets.QToolButton() + search.setIcon(qicons.search) + search.clicked.connect(menu.close) + quick_filter_layout = QtWidgets.QHBoxLayout() + quick_filter_layout.addWidget(self.input_line) + quick_filter_layout.addWidget(search) + quick_filter_widget = QtWidgets.QWidget() + quick_filter_widget.setLayout(quick_filter_layout) + quick_filter_widget.setToolTip( + "Filter this column on the input,\n" + "press 'enter' or the search button to filter" + ) + # write previous filter to the quick-filter input if we have one + if prev_filter := self.prev_quick_filter.get(self.selected_column, False): + self.input_line.setText(prev_filter[1]) + else: + self.input_line.setPlaceholderText("Quick filter ...") + self.input_line.textChanged.connect(self.debounce_quick_filter.start) + self.input_line.returnPressed.connect(menu.close) + QAline = QtWidgets.QWidgetAction(self) + QAline.setDefaultWidget(quick_filter_widget) + menu.addAction(QAline) + + # More filters submenu + mf_menu = QtWidgets.QMenu(menu) + mf_menu.setToolTipsVisible(True) + mf_menu.setIcon(qicons.filter) + mf_menu.setTitle("More filters") + filter_actions = [] + for i, f in enumerate(self.FILTER_TYPES[col_type]): + fa = QtWidgets.QAction(text=f) + fa.setToolTip(self.FILTER_TYPES[col_type + "_tt"][i]) + fa.triggered.connect(self.simple_filter_dialog) + filter_actions.append(fa) + for fa in filter_actions: + mf_menu.addAction(fa) + menu.addMenu(mf_menu) + # edit filters main menu + filter_man = QtWidgets.QAction(qicons.edit, "Manage filters") + filter_man.triggered.connect(self.filter_manager_dialog) + filter_man.setToolTip("Open the filter management menu") + menu.addAction(filter_man) + # delete column filters option + col_del = QtWidgets.QAction(qicons.delete, "Remove column filters") + col_del.triggered.connect(self.reset_column_filters) + col_del.setToolTip("Remove all filters on this column") + menu.addAction(col_del) + col_del.setEnabled(False) + if isinstance(self.filters, dict) and self.filters.get( + self.selected_column, False + ): + col_del.setEnabled(True) + # delete all filters option + all_del = QtWidgets.QAction(qicons.delete, "Remove all filters") + all_del.triggered.connect(self.reset_filters) + all_del.setToolTip("Remove all filters in this table") + menu.addAction(all_del) + all_del.setEnabled(False) + if isinstance(self.filters, dict): + all_del.setEnabled(True) + + # Show existing filters for column + if isinstance(self.filters, dict) and self.filters.get( + self.selected_column, False + ): + menu.addSeparator() + active_filters_label = QtWidgets.QAction( + qicons.filter, "Active column filters:" + ) + active_filters_label.setEnabled(False) + menu.addAction(active_filters_label) + active_filters = [] + for filter_data in self.filters[self.selected_column]["filters"]: + if filter_data[0] == "<= x <=": + q = " and ".join(filter_data[1]) + else: + q = filter_data[1] + filter_str = ": ".join([filter_data[0], q]) + f = QtWidgets.QAction(text=filter_str) + f.setEnabled(False) + active_filters.append(f) + for f in active_filters: + menu.addAction(f) + + self.input_line.setFocus() + loc = self.header.event_pos + menu.exec_(self.mapToGlobal(loc)) + + @Slot(name="updateProxyModel") + def update_proxy_model(self) -> None: + self.proxy_model = ABMultiColumnSortProxyModel(self) + self.proxy_model.setSourceModel(self.model) + self.proxy_model.setSortCaseSensitivity(Qt.CaseInsensitive) + self.setModel(self.proxy_model) + + def quick_filter(self) -> None: + # remove weird whitespace from input + query = ( + self.input_line.text().translate(str.maketrans("", "", "\n\t\r")).strip() + ) + + # convert to filter + col_name = {v: k for k, v in self.model.filterable_columns.items()}[ + self.selected_column + ] + if self.model.different_column_types.get(col_name): + # column is type 'num' + filt = ("=", query) + else: + # column is type 'str' + filt = ("contains", query, False) + # check if quick filter exists for this col, if so; remove from self.filters + if prev_filter := self.prev_quick_filter.get(self.selected_column, False): + self.filters[self.selected_column]["filters"].remove(prev_filter) + + # place the filter in self.prev_quick_filter for next quick filter on this column + self.prev_quick_filter[self.selected_column] = filt + + # apply the right filters + if query != "": + # the query is not empty, add it to the filters and apply them + self.add_filter(filt) + self.apply_filters() + elif len(self.filters[self.selected_column]["filters"]) > 0: + # the query is empty, but there are still filters for this column, so apply the filters + self.apply_filters() + else: + # the query is empty, and there are no more filters for this column, reset this filter. + self.reset_column_filters() + + def filter_manager_dialog(self) -> None: + # get right data + column_names = self.model.filterable_columns + + # show dialog + dialog = FilterManagerDialog( + column_names=column_names, + filters=self.filters, + filter_types=self.FILTER_TYPES, + selected_column=self.selected_column, + column_types=self.model.different_column_types, + ) + if dialog.exec_() == FilterManagerDialog.Accepted: + # set the filters + filters = dialog.get_filters + if filters != self.filters: + # the filters returned from the dialog are different, actually apply the filters + rm = [] + for col, qf in self.prev_quick_filter.items(): + # check if quickfilters exist for these columns, otherwise remove them + if ( + filters.get(col, False) and qf not in filters[col]["filters"] + ) or not filters.get(col, False): + rm.append(col) + for col in rm: + self.prev_quick_filter.pop(col) + self.write_filters(filters) + self.apply_filters() + + def simple_filter_dialog(self, preset_type: str = None) -> None: + if not preset_type: + preset_type = self.sender().text() + + # get right data + column_name = {v: k for k, v in self.model.filterable_columns.items()}[ + self.selected_column + ] + col_type = self.model.different_column_types.get(column_name, "str") + + # show dialog + dialog = SimpleFilterDialog( + column_name=column_name, + filter_types=self.FILTER_TYPES, + column_type=col_type, + preset_type=preset_type, + ) + if dialog.exec_() == SimpleFilterDialog.Accepted: + new_filter = dialog.get_filter + # add the filter to existing filters + if new_filter: + self.add_filter(new_filter) + self.apply_filters() + + def add_filter(self, new_filter: tuple) -> None: + """Add a single filter to self.filters.""" + if isinstance(self.filters, dict): + # filters exist + all_filters = self.filters + if all_filters.get(self.selected_column, False): + # filters exist for this column + all_filters[self.selected_column]["filters"].append(new_filter) + if ( + not all_filters[self.selected_column].get("mode", False) + and len(all_filters[self.selected_column]["filters"]) > 1 + ): + # a mode does not exist, but there are multiple filters + all_filters[self.selected_column]["mode"] = "OR" + else: + # filters don't yet exist for this column: + all_filters[self.selected_column] = {"filters": [new_filter]} + else: + # no filters exist + all_filters = { + self.selected_column: {"filters": [new_filter]}, + "mode": "AND", + } + + self.write_filters(all_filters) + + def write_filters(self, filters: dict) -> None: + self.filters = filters + + def apply_filters(self) -> None: + if self.filters: + QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) + # only allow filters that are for columns that may be filtered on + filters = { + k: v + for k, v in self.filters.items() + if k in list(self.model.filterable_columns.values()) + ["mode"] + } + self.proxy_model.set_filters(self.model.get_filter_mask(filters)) + self.header.has_active_filters = list(filters.keys()) + QtWidgets.QApplication.restoreOverrideCursor() + else: + self.reset_filters() + + def reset_column_filters(self) -> None: + """Reset all filters for this column.""" + QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) + f = self.filters + if f.get(self.selected_column, False): + f.pop(self.selected_column) + if self.prev_quick_filter.get(self.selected_column, False): + self.prev_quick_filter.pop(self.selected_column) + self.write_filters(f) + if len(self.filters) == 1 and self.filters.get("mode"): + # the only thing in filters remaining is the mode --> there are no filters + self.reset_filters() + else: + self.header.has_active_filters = list(self.filters.keys()) + self.apply_filters() + QtWidgets.QApplication.restoreOverrideCursor() + + def reset_filters(self) -> None: + """Reset all filters for this entire table.""" + QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) + self.write_filters(None) + self.header.has_active_filters = [] + self.prev_quick_filter = {} + self.proxy_model.clear_filters() + QtWidgets.QApplication.restoreOverrideCursor() + + +class LCAResultsModel(PandasModel): + def sync(self, df): + self._dataframe = df.replace(np.nan, "", regex=True) + self.updated.emit() + + +class LCAResultsTable(ABDataFrameView): + def __init__(self, parent=None): + super().__init__(parent) + self.model = LCAResultsModel(parent=self) + self.model.updated.connect(self.update_proxy_model) + + +class InventoryModel(PandasModel): + def sync(self, df): + self._dataframe = df + # set the visible columns + self.filterable_columns = { + col: i for i, col in enumerate(self._dataframe.columns.to_list()) + } + # set the columns te be defined as num (all except the first five for both biopshere and technosphere + self.different_column_types = { + col: "num" + for i, col in enumerate(self._dataframe.columns.to_list()) + if i >= 5 + } + self.updated.emit() + + +class InventoryTable(ABFilterableDataFrameView): + def __init__(self, parent=None): + super().__init__(parent) + self.horizontalHeader().setStretchLastSection(True) + + self.model = InventoryModel(parent=self) + self.model.updated.connect(self.update_proxy_model) + self.model.updated.connect(self.update_filter_data) + # below variables are required for switching between technosphere and biosphere tables + self.showing = None + self.filters_tec = None + self.filters_bio = None + + def update_filter_data(self) -> None: + if self.showing == "technosphere": + self.filters = self.filters_tec + else: + self.filters = self.filters_bio + + # update the column header indices + if isinstance(self.model.filterable_columns, dict): + self.header.column_indices = list(self.model.filterable_columns.values()) + # apply the existing filters + self.apply_filters() + + def write_filters(self, filters: dict) -> None: + if self.showing == "technosphere": + self.filters_tec = filters + else: + self.filters_bio = filters + self.filters = filters + + +class ContributionModel(PandasModel): + def sync(self, df, unit="relative share"): + + if "unit" in df.columns: + # overwrite the unit col with 'relative share' if looking at relative results (except 3 'total' and 'rest' rows) + df["unit"] = [""] * 3 + [unit] * (len(df) - 3) + + # drop any rows where all numbers are 0 + self._dataframe = df.loc[~(df.select_dtypes(include=np.number) == 0).all(axis=1)] + self.updated.emit() + + +class ContributionTable(ABDataFrameView): + def __init__(self, parent=None): + super().__init__(parent) + self.model = ContributionModel(parent=self) + self.model.updated.connect(self.update_proxy_model) diff --git a/activity_browser/app/pages/lca_results/tree_navigator.py b/activity_browser/app/pages/lca_results/tree_navigator.py new file mode 100644 index 000000000..d3d2ba158 --- /dev/null +++ b/activity_browser/app/pages/lca_results/tree_navigator.py @@ -0,0 +1,506 @@ +import json +import time +from typing import List, Optional +from loguru import logger + +import bw2calc as bc +import bw2data as bd +from qtpy import QtWidgets +from qtpy.QtCore import Slot +from qtpy.QtWidgets import QComboBox +from bw_graph_tools.graph_traversal import ( + SameNodeEachVisitGraphTraversal, + SameNodeEachVisitTaggedGraphTraversal, + GraphTraversalSettings, + TaggedGraphTraversalSettings, +) +from bw_graph_tools.graph_traversal.graph_objects import ( + Node as GraphNode, + Edge as GraphEdge, + GroupedNodes as GraphGroupedNodes, +) +from bw2data.backends import ActivityDataset + +from activity_browser import app +from activity_browser.bwutils.filesystem import get_package_path +from activity_browser.bwutils.commontasks import identify_activity_type +from activity_browser.ui import widgets + + +class SmallComboBox(QtWidgets.QComboBox): + """A small combo box that does not expand to fill the available space.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + self.setMinimumWidth(100) + self.setMaximumWidth(200) + self.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContentsOnFirstShow) + + +class TreeNavigatorWidget(widgets.ABAbstractNavigator): + HELP_TEXT = """ + LCA Dynamic Tree Navigator: + + Red flows: Impacts + Green flows: Avoided impacts + + """ + HTML_FILE = str(get_package_path() / "static" / "tree_navigator.html") + + def __init__(self, cs_name, parent=None): + super().__init__(parent, css_file="tree_navigator.css") + + self.cache = {} # we cache the calculated data to improve responsiveness + self.parent = parent + self.has_scenarios = self.parent.has_scenarios + self.cs = cs_name + self.selected_db = None + self.has_rendered_once = False + self.func_units = [] + self.methods = [] + self.scenarios = [] + self.graph = Graph() + + # Additional Qt objects + self.scenario_label = QtWidgets.QLabel("Scenario: ") + self.func_unit_cb = SmallComboBox() + self.method_cb = SmallComboBox() + self.scenario_cb = SmallComboBox() + self.tag_cb = widgets.CheckableComboBox() + self.cutoff_sb = QtWidgets.QDoubleSpinBox() + self.max_calc_sb = QtWidgets.QDoubleSpinBox() + self.button_calculate = QtWidgets.QPushButton("Calculate") + self.layout = QtWidgets.QVBoxLayout() + + # graph + self.draw_graph() + self.construct_layout() + self.connect_signals() + + @Slot(name="loadFinishedHandler") + def load_finished_handler(self) -> None: + self.send_json() + + def connect_signals(self): + super().connect_signals() + self.button_calculate.clicked.connect(self.new_tree) + app.signals.database_selected.connect(self.set_database) + # checkboxes + self.func_unit_cb.currentIndexChanged.connect(self.new_tree) + self.method_cb.currentIndexChanged.connect(self.new_tree) + self.scenario_cb.currentIndexChanged.connect(self.new_tree) + self.tag_cb.onHidePopup.connect(self.new_tree) + self.bridge.update_graph.connect(self.update_graph) + + def construct_layout(self) -> None: + """Layout of Sankey Navigator""" + super().construct_layout() + self.label_help.setVisible(False) + + # Layout Reference Flows and Impact Categories + grid_lay = QtWidgets.QGridLayout() + grid_lay.addWidget(QtWidgets.QLabel("Reference flow: "), 0, 0) + + grid_lay.addWidget(self.scenario_label, 1, 0) + grid_lay.addWidget(QtWidgets.QLabel("Impact indicator: "), 2, 0) + grid_lay.addWidget(QtWidgets.QLabel("Tag System: "), 2, 2) + + self.update_calculation_setup() + + grid_lay.addWidget(self.func_unit_cb, 0, 1, 1, 3) + grid_lay.addWidget(self.scenario_cb, 1, 1) + grid_lay.addWidget(self.method_cb, 2, 1) + grid_lay.addWidget(self.tag_cb, 2, 3) + + # cut-off + grid_lay.addWidget(QtWidgets.QLabel("Cutoff: "), 2, 4) + self.cutoff_sb.setRange(0.0, 1.0) + self.cutoff_sb.setSingleStep(0.01) + self.cutoff_sb.setDecimals(3) + self.cutoff_sb.setValue(0.05) + self.cutoff_sb.setKeyboardTracking(False) + grid_lay.addWidget(self.cutoff_sb, 2, 5) + + # max-iterations of graph traversal + grid_lay.addWidget(QtWidgets.QLabel("Calculation depth: "), 2, 6) + self.max_calc_sb.setRange(1, 2000) + self.max_calc_sb.setSingleStep(50) + self.max_calc_sb.setDecimals(0) + self.max_calc_sb.setValue(250) + self.max_calc_sb.setKeyboardTracking(False) + grid_lay.addWidget(self.max_calc_sb, 2, 7) + + grid_lay.setColumnStretch(6, 1) + hlay = QtWidgets.QHBoxLayout() + hlay.addLayout(grid_lay) + + # Controls Layout + # hl_controls = QtWidgets.QHBoxLayout() + grid_lay.addWidget(self.button_calculate, 0, 5) + grid_lay.addWidget(self.button_refresh, 0, 6) + grid_lay.addWidget(self.button_toggle_help, 0, 7) + # hl_controls.addStretch(1) + + # Layout + self.layout.addLayout(hlay) + # self.layout.addLayout(hl_controls) + self.layout.addWidget(self.label_help) + self.layout.addWidget(self.view) + self.setLayout(self.layout) + + def get_scenario_labels(self) -> List[str]: + """Get scenario labels if scenario is used.""" + return self.parent.mlca.scenario_names if self.has_scenarios else [] + + def configure_scenario(self): + """Determine if scenario Qt widgets are visible or not and retrieve + scenario labels for the selection drop-down box. + """ + self.scenario_cb.setVisible(self.has_scenarios) + self.scenario_label.setVisible(self.has_scenarios) + if self.has_scenarios: + self.scenarios = self.get_scenario_labels() + self.update_combobox(self.scenario_cb, self.scenarios) + + @staticmethod + def update_combobox(box: QComboBox, labels: List[str]) -> None: + """Update the combobox menu.""" + box.blockSignals(True) + box.clear() + box.insertItems(0, labels) + box.blockSignals(False) + + def update_calculation_setup(self, cs_name=None) -> None: + """Update Calculation Setup, reference flows and impact categories, and dropdown menus.""" + # block signals + block_signals = [self.func_unit_cb, self.method_cb, self.tag_cb] + for b in block_signals: + b.blockSignals(True) + + self.cs = cs_name or self.cs + self.func_units = [ + {bd.get_activity(k): v for k, v in fu.items()} + for fu in bd.calculation_setups[self.cs]["inv"] + ] + self.methods = bd.calculation_setups[self.cs]["ia"] + self.func_unit_cb.clear() + fu_acts = [list(fu.keys())[0] for fu in self.func_units] + self.func_unit_cb.addItems( + [f"{repr(a)} | {a._data.get('database')}" for a in fu_acts] + ) + self.configure_scenario() + self.method_cb.clear() + self.method_cb.addItems([repr(m) for m in self.methods]) + + # tags + self.tag_cb.clear() + self.tag_cb.addItems([]) + + # unblock signals + for b in block_signals: + b.blockSignals(False) + + def new_tree(self) -> None: + """(re)-generate the tree diagram.""" + demand_index = self.func_unit_cb.currentIndex() + method_index = self.method_cb.currentIndex() + self.update_tree( + self.func_units[demand_index], + self.methods[method_index], + demand_index=demand_index, + method_index=method_index, + scenario_index=self.scenario_cb.currentIndex() if self.has_scenarios else None, + scenario_lca=bool(self.has_scenarios), + cut_off=self.cutoff_sb.value(), + max_calc=int(self.max_calc_sb.value()), + tags=self.tag_cb.currentData(), + ) + + def update_tree( + self, + demand: dict, + method: tuple, + demand_index: int = None, + method_index: int = None, + scenario_index: int = None, + scenario_lca: bool = False, + cut_off=0.05, + max_calc=100, + tags=None, + ) -> None: + """Calculate LCA, do graph traversal, get JSON graph data for this, and send to javascript.""" + + # the cache key consists of demand/method/scenario indices (index of item in the relevant tables), + # the cutoff, max_calc. + # together, these are unique. + cache_key = ( + demand_index, + method_index, + scenario_index, + cut_off, + max_calc, + str(tags), + ) + if data := self.cache.get(cache_key, False): + # this Graph is already cached, generate the tree with Graph cached data + logger.debug(f"CACHED tree for: {demand}, {method}, key: {cache_key}") + self.graph.new_graph(data) + self.has_rendered_once = bool(self.graph.json_data) + self.send_json() + return + + start = time.time() + logger.debug(f"CALCULATE tree for: {demand}, {method}, key: {cache_key}") + + try: + if scenario_lca: + self.parent.mlca.update_lca_calculation_for_sankey( + scenario_index, demand, method_index + ) + + if not hasattr(self, "cached_lca"): + fu, data_objs, _ = bd.prepare_lca_inputs(demand=demand, method=method) + self.cached_lca = bc.LCA(demand=fu, data_objs=data_objs) + self.cached_lca.lci(factorize=True) + self.cached_lca.lcia() + if tags: + data = SameNodeEachVisitTaggedGraphTraversal( + lca=self.cached_lca, + settings=TaggedGraphTraversalSettings( + tags=tags, cutoff=cut_off, max_calc=max_calc + ), + ) + else: + data = SameNodeEachVisitGraphTraversal( + lca=self.cached_lca, + settings=GraphTraversalSettings( + cutoff=cut_off, max_calc=max_calc + ), + ) + data.traverse(depth=2) + + # store the metadata from this calculation + data.metadata = { + "unit": bd.methods[method]["unit"], + } + except (ValueError, ZeroDivisionError) as e: + QtWidgets.QMessageBox.information( + None, "Nonsensical numeric result.", str(e) + ) + logger.debug(f"Completed graph traversal ({round(time.time() - start, 2)} seconds") + + # cache the generated Graph data + self.cache[cache_key] = data + + # generate the new Graph + self.graph.new_graph(data) + self.has_rendered_once = bool(self.graph.json_data) + self.send_json() + + def set_database(self, name): + """Saves the currently selected database for graphing a random activity""" + self.selected_db = name + + def random_graph(self) -> None: + """Show graph for a random activity in the currently loaded database.""" + if self.selected_db: + method = bd.methods.random() + act = bd.Database(self.selected_db).random() + demand = {act: 1.0} + self.update_tree(demand, method) + else: + QtWidgets.QMessageBox.information( + None, "Not possible.", "Please load a database first." + ) + + @Slot(object, name="update_graph") + def update_graph(self, click_dict: dict) -> None: + """Update the graph with the specified JSON data.""" + traversed = self.graph.state_graph.traverse_from_node(click_dict["id"]) + if not traversed: + # nothing has changed + return + self.graph.json_data = Graph.get_json_data(self.graph.state_graph) + self.send_json() + + +class Graph(widgets.ABAbstractGraph): + """ + Python side representation of the graph. + Functionality for graph navigation (e.g. adding and removing nodes). + A JSON representation of the graph (edges and nodes) enables its use in javascript/html/css. + """ + + def __init__(self): + super().__init__() + self.state_graph: Optional["SameNodeEachVisitGraphTraversal"] = None + + @staticmethod + def get_data_from_state_graph(state_graph: "SameNodeEachVisitGraphTraversal"): + return { + "nodes": state_graph.nodes, + "edges": state_graph.edges, + "flows": state_graph.flows, + "calculation_count": state_graph.calculation_count.value, + "metadata": state_graph.metadata, + } + + def new_graph(self, state_graph: "SameNodeEachVisitGraphTraversal"): + self.state_graph = state_graph + self.json_data = Graph.get_json_data(state_graph) + self.update() + + @staticmethod + def get_json_data(state_graph: "SameNodeEachVisitGraphTraversal") -> str: + """Transform graph traversal output to JSON data. + + We use the [dagre](https://github.com/dagrejs/dagre) javascript library for rendering directed graphs. We need to provide the following: + + ```python + { + 'max_impact': float, # Total LCA score, + 'title': str, # Graph title + 'edges': [{ + 'source_id': int, # Unique ID of producer of material or energy in graph + 'target_id': int, # Unique ID of consumer of material or energy in graph + 'weight': float, # In graph units, relative to `max_edge_width` + 'label': str, # HTML label + 'product': str, # The label of the flowing material or energy + 'class': str, # "benefit" or "impact"; controls styling + 'label': str, # HTML label + 'toottip': str, # HTML tooltip + }], + 'nodes': [{ + 'direct_emissions_score_normalized': float, # Fraction of total LCA score from direct emissions + 'product': str, # Reference product label, if any + 'location': str, # Location, if any + 'id': int, # Graph traversal ID + 'database_id': int, # Node ID in SQLite database + 'database': str, # Database name + 'class': str, # Enumerated set of class label strings + 'label': str, # HTML label including name and location + 'toottip': str, # HTML tooltip + }], + } + + ``` + + """ + lca_score = state_graph.lca.score + lcia_unit = state_graph.metadata["unit"] + demand = state_graph.lca.demand + + def convert_edge_to_json( + edge: GraphEdge, + nodes: dict[int, GraphNode], + total_score: float, + lcia_unit: str, + max_edge_width: int = 40, + ) -> dict: + cum_score = nodes[edge.producer_unique_id].cumulative_score + node = nodes[edge.producer_unique_id] + if isinstance(node, GraphGroupedNodes): + unit = "" + else: + unit = bd.get_node( + id=nodes[edge.producer_unique_id].reference_product_datapackage_id + ).get("unit", "(unknown)") + return { + "source_id": edge.producer_unique_id, + "target_id": edge.consumer_unique_id, + "amount": edge.amount, + "weight": abs(cum_score / total_score) * max_edge_width, + "label": f"{round(cum_score, 3)} {lcia_unit}", + "class": "benefit" if cum_score < 0 else "impact", + "tooltip": f"{round(cum_score, 3)} {lcia_unit} ({edge.amount:.2g} {unit})", + } + + def convert_node_to_json( + graph_node: GraphNode, + total_score: float, + fu: dict, + lcia_unit: str, + max_name_length: int = 20, + ) -> dict: + expanded = graph_node.unique_id in state_graph.visited_nodes + if isinstance(graph_node, GraphGroupedNodes): + data = { + "direct_emissions_score_normalized": graph_node.direct_emissions_score + / (total_score or 1), + "direct_emissions_score": graph_node.direct_emissions_score, + "cumulative_score": graph_node.cumulative_score, + "cumulative_score_normalized": graph_node.cumulative_score + / (total_score or 1), + "product": "", + "location": "", + "id": graph_node.unique_id, + "database_id": "", + "database": "", + "class": "", + "name": graph_node.label, + "expanded": expanded, + } + else: + db_node = bd.get_node(id=graph_node.activity_datapackage_id) + data = { + "direct_emissions_score_normalized": graph_node.direct_emissions_score + / (total_score or 1), + "direct_emissions_score": graph_node.direct_emissions_score, + "cumulative_score": graph_node.cumulative_score, + "cumulative_score_normalized": graph_node.cumulative_score + / (total_score or 1), + "product": db_node.get("reference product", ""), + "location": db_node.get("location", "(unknown)"), + "id": graph_node.unique_id, + "database_id": graph_node.activity_datapackage_id, + "database": db_node["database"], + "class": ( + "demand" + if graph_node.activity_datapackage_id in fu + else identify_activity_type(db_node) + ), + "name": db_node.get("name", "(unnamed)"), + "expanded": expanded, + } + frac_dir_score = round(data["direct_emissions_score_normalized"] * 100, 2) + dir_score = round(data["direct_emissions_score"], 3) + frac_cum_score = round(data["cumulative_score_normalized"] * 100, 2) + cum_score = round(data["cumulative_score"], 3) + if isinstance(graph_node, GraphGroupedNodes): + data["label"] = data["name"] + else: + data["label"] = "{}\n{}\n{}".format( + db_node["name"][:max_name_length], data["location"], frac_dir_score + ) + data[ + "tooltip" + ] = f""" + {data['name']} +
Individual impact: {dir_score} {lcia_unit} ({frac_dir_score}%) +
Cumulative impact: {cum_score} {lcia_unit} ({frac_cum_score}%) +
Expanded: {expanded} + """ + return data + + json_data = { + "nodes": [ + convert_node_to_json(node, lca_score, demand, lcia_unit) + for idx, node in state_graph.nodes.items() + if idx != -1 + ], + "edges": [ + convert_edge_to_json(edge, state_graph.nodes, lca_score, lcia_unit) + for edge in state_graph.edges + if edge.producer_index != -1 and edge.consumer_index != -1 + ], + "title": None, + } + + return json.dumps(json_data) + + +def id_to_key(id): + if isinstance(id, tuple): + return id + return ActivityDataset.get_by_id(id).database, ActivityDataset.get_by_id(id).code diff --git a/activity_browser/app/pages/metadatastore.py b/activity_browser/app/pages/metadatastore.py new file mode 100644 index 000000000..b1336b260 --- /dev/null +++ b/activity_browser/app/pages/metadatastore.py @@ -0,0 +1,36 @@ +from qtpy import QtWidgets +from loguru import logger + +from activity_browser.ui import widgets, delegates, core +from activity_browser.app import metadata, signals + + +class MetaDataStorePage(QtWidgets.QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("MetaDataStorePage") + + self.model = core.ABTreeModel(metadata.dataframe, self, chunk_size=50) + self.view = MDSView(self) + self.view.setModel(self.model) + + self.build_layout() + self.connect_signals() + + def connect_signals(self): + signals.metadata.synced.connect(self.sync) + + def sync(self): + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + self.model.set_dataframe(metadata.dataframe) + + def build_layout(self): + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.view) + self.setLayout(layout) + + +class MDSView(widgets.ABTreeView): + def __init__(self, parent=None): + super().__init__(parent) + self.setItemDelegate(delegates.StringDelegate(self)) diff --git a/activity_browser/app/pages/parameters/__init__.py b/activity_browser/app/pages/parameters/__init__.py new file mode 100644 index 000000000..dd02adad4 --- /dev/null +++ b/activity_browser/app/pages/parameters/__init__.py @@ -0,0 +1,2 @@ +from .parameters import ParametersPage + diff --git a/activity_browser/app/pages/parameters/parameterized_exchanges_section.py b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py new file mode 100644 index 000000000..be4a2663e --- /dev/null +++ b/activity_browser/app/pages/parameters/parameterized_exchanges_section.py @@ -0,0 +1,296 @@ +from loguru import logger + +from qtpy import QtWidgets, QtCore +from qtpy.QtCore import Qt + +import pandas as pd +import bw2data as bd +from bw2data.parameters import ParameterizedExchange +from bw2data.backends import ExchangeDataset + +from activity_browser import app +from activity_browser.ui import widgets, icons, delegates, core +from activity_browser.bwutils.commontasks import database_is_locked +from activity_browser.bwutils.utils import Parameter + + +class ParameterizedExchangesSection(QtWidgets.QWidget): + """ + A widget section that displays all parameterized exchanges in the current project. + + Attributes: + model (ParameterizedExchangesModel): The model containing the data for the exchanges. + view (ParameterizedExchangesView): The view displaying the exchanges. + """ + + def __init__(self, parent=None): + """ + Initializes the ParameterizedExchangesSection widget. + + Args: + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + self._populate_later_flag = False + + # Parameterized exchanges table view + self.model = ParameterizedExchangesModel(parent=self) + self.view = ParameterizedExchangesView() + self.view.setModel(self.model) + + self.build_layout() + self.connect_signals() + + def build_layout(self): + """ + Builds the layout of the widget. + """ + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.view) + self.setLayout(layout) + + def connect_signals(self): + """ + Connects signals to their respective slots. + """ + app.signals.metadata.synced.connect(self.syncLater) + app.signals.parameter.changed.connect(self.syncLater) + app.signals.parameter.recalculated.connect(self.syncLater) + app.signals.parameter.deleted.connect(self.syncLater) + app.signals.project.changed.connect(self.syncLater) + app.signals.meta.databases_changed.connect(self.syncLater) + + def syncLater(self): + """ + Schedules a sync operation to be performed later. + """ + + def slot(): + self._populate_later_flag = False + self.sync() + self.thread().eventDispatcher().awake.disconnect(slot) + + if self._populate_later_flag: + return + + self._populate_later_flag = True + self.thread().eventDispatcher().awake.connect(slot) + + def sync(self): + """ + Synchronizes the widget with the current state of parameterized exchanges. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + df = self.build_exchanges_df() + self.model.set_dataframe(df) + + def build_exchanges_df(self) -> pd.DataFrame: + """ + Builds a DataFrame from all parameterized exchanges in the project. + + Returns: + pd.DataFrame: The DataFrame containing the parameterized exchanges data. + """ + translated = [] + + # Get all parameterized exchanges + for param_exc in ParameterizedExchange.select(): + try: + exchange = bd.Edge(document=ExchangeDataset.get_by_id(param_exc.exchange)) + + # Get keys for input and output + input_key = exchange.get("input") + output_key = exchange.get("output") + + # Get metadata from metadata store + input_meta = app.metadata.get_metadata([input_key], ["name", "unit", "location", "database", "product"]).iloc[0] + output_meta = app.metadata.get_metadata([output_key], ["name"]).iloc[0] + + row = { + "amount": exchange.get("amount"), + "unit": input_meta.get("unit"), + "from": input_meta.get("product") or input_meta.get("name"), + "to": output_meta.get("name"), + "database": input_meta.get("database"), + "formula": exchange.get("formula"), + "comment": exchange.get("comment"), + "uncertainty": exchange.get("uncertainty type"), + "_exchange": exchange, + "_output_key": output_key, + "_input_key": input_key, + } + translated.append(row) + except Exception as e: + # Skip if exchange can't be loaded + continue + + columns = ["amount", "unit", "from", "to", "database", "formula", "comment", "uncertainty", "_exchange", "_output_key", "_input_key"] + return pd.DataFrame(translated, columns=columns) + + +class ParameterizedExchangesView(widgets.ABTreeView): + """ + A view that displays parameterized exchanges in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + """ + defaultColumnDelegates = { + "amount": delegates.FloatDelegate, + "unit": delegates.StringDelegate, + "product": delegates.StringDelegate, + "producer": delegates.StringDelegate, + "location": delegates.StringDelegate, + "database": delegates.StringDelegate, + "formula": delegates.NewFormulaDelegate, + "comment": delegates.StringDelegate, + "uncertainty": delegates.UncertaintyDelegate, + } + + class ContextMenu(QtWidgets.QMenu): + """ + A context menu for the ParameterizedExchangesView. + """ + def __init__(self, pos, view: "ParameterizedExchangesView"): + """ + Initializes the ContextMenu. + + Args: + pos: The position of the context menu. + view (ParameterizedExchangesView): The view displaying the exchanges. + """ + super().__init__(view) + + index = view.indexAt(pos) + if index.isValid() and not view.model().isBranchNode(index): + row = view.model().row(index) + if row is not None: + output_key = row.get("_output_key") + if output_key: + # Open activity action + open_action = app.actions.ActivityOpen.get_QAction([output_key]) + open_action.setText("Open activity") + self.addAction(open_action) + + +class ParameterizedExchangesModel(core.ABTreeModel): + """ + A model representing the data for parameterized exchanges. + """ + + def __init__(self, parent=None): + """ + Initializes the ParameterizedExchangesModel. + + Args: + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(df=pd.DataFrame(), parent=parent) + + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: + """ + Sets the data for the given index. + + Args: + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. + + Returns: + bool: True if the data was set successfully, False otherwise. + """ + if role != Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False + + exchange = row.get("_exchange") + if exchange is None: + return False + + if column_name in ["amount", "formula", "comment"]: + if column_name == "formula" and not str(value).strip(): + # Remove formula if empty + app.actions.ExchangeFormulaRemove.run([exchange]) + return True + + app.actions.ExchangeModify.run(exchange, {column_name.lower(): value}) + return True + + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: + """ + Provides decoration data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide decoration data. + + Returns: + The decoration data for the index. + """ + column_name = self.column_name(index) + + if column_name == "amount": + formula = self.get(index, "formula") + if pd.isna(formula) or formula is None or formula == "": + return icons.qicons.edit + return icons.qicons.parameterized + + return None + + def indexEditable(self, index: QtCore.QModelIndex) -> bool: + """ + Returns whether the index is editable. + + Args: + index (QtCore.QModelIndex): The index to check. + + Returns: + bool: True if the index is editable, False otherwise. + """ + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False + + # Check if database is locked + exchange = row.get("_exchange") + if exchange and database_is_locked(exchange.output["database"]): + return False + + # Allow editing for specific columns + if column_name in ["amount", "formula", "comment"]: + return True + + return False + + def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: + """ + Returns the parameters in scope of the exchange at the given index. + + Args: + index (QtCore.QModelIndex): The index to get scoped parameters for. + + Returns: + dict: The parameters in scope. + """ + from activity_browser.bwutils.commontasks import parameters_in_scope + + row = self.row(index) + if row is None: + return {} + + exchange = row.get("_exchange") + if exchange is None: + return {} + + return parameters_in_scope(node=exchange.output) + diff --git a/activity_browser/app/pages/parameters/parameters.py b/activity_browser/app/pages/parameters/parameters.py new file mode 100644 index 000000000..72c953230 --- /dev/null +++ b/activity_browser/app/pages/parameters/parameters.py @@ -0,0 +1,65 @@ +from qtpy import QtWidgets, QtCore + +from activity_browser.ui import widgets + +from .parameters_section import ParametersSection +from .parameterized_exchanges_section import ParameterizedExchangesSection + + +class ParametersPage(QtWidgets.QWidget): + """ + A widget that displays all parameters and parameterized exchanges in the current project. + + This page shows: + - Parameters section: A tree view of parameters organized by scope + - Parameterized exchanges section: A table of exchanges with formulas + """ + + def __init__(self, parent=None): + """ + Initializes the ParametersPage widget. + + Args: + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + + self.parameters_section = ParametersSection(self) + self.parameterized_exchanges_section = ParameterizedExchangesSection(self) + + self.build_layout() + + def build_layout(self): + """ + Builds the layout of the widget. + """ + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 3, 0, 0) + + # Add both sections in a splitter + splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical, self) + + # Parameters section + params_widget = QtWidgets.QWidget() + params_layout = QtWidgets.QVBoxLayout(params_widget) + params_layout.setContentsMargins(0, 0, 0, 0) + params_label = widgets.ABLabel.demiBold(" Parameters") + params_layout.addWidget(params_label) + params_layout.addWidget(widgets.ABHLine(self)) + params_layout.addWidget(self.parameters_section) + splitter.addWidget(params_widget) + + # Parameterized exchanges section + exchanges_widget = QtWidgets.QWidget() + exchanges_layout = QtWidgets.QVBoxLayout(exchanges_widget) + exchanges_layout.setContentsMargins(0, 0, 0, 0) + exchanges_label = widgets.ABLabel.demiBold(" Parameterized Exchanges") + exchanges_layout.addWidget(exchanges_label) + exchanges_layout.addWidget(widgets.ABHLine(self)) + exchanges_layout.addWidget(self.parameterized_exchanges_section) + splitter.addWidget(exchanges_widget) + + layout.addWidget(splitter) + self.setLayout(layout) + + diff --git a/activity_browser/app/pages/parameters/parameters_section.py b/activity_browser/app/pages/parameters/parameters_section.py new file mode 100644 index 000000000..aa6f3df5e --- /dev/null +++ b/activity_browser/app/pages/parameters/parameters_section.py @@ -0,0 +1,443 @@ +from loguru import logger + +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt + +import pandas as pd +import bw2data as bd +from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter, Group + +from activity_browser import app +from activity_browser.ui import widgets, icons, delegates, core +from activity_browser.bwutils.commontasks import refresh_parameter, database_is_locked, parameters_in_scope +from activity_browser.bwutils.utils import Parameter + + +class ParametersSection(QtWidgets.QWidget): + """ + A widget section that displays all parameters in the current project. + + This section shows a tree view of parameters organized by scope: + - Project parameters + - Database parameters (grouped by database) + - Activity parameters (grouped by activity group) + + Attributes: + model (ProjectParametersModel): The model containing the data for the parameters. + view (ProjectParametersView): The view displaying the parameters. + """ + + def __init__(self, parent=None): + """ + Initializes the ParametersSection widget. + + Args: + parent (QtWidgets.QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + self._populate_later_flag = False + + # Parameters tree view + self.model = ProjectParametersModel(parent=self) + self.view = ProjectParametersView() + self.view.setSortingEnabled(False) + self.view.setUniformRowHeights(True) + + self.view.setModel(self.model) + + self.build_layout() + self.connect_signals() + + def build_layout(self): + """ + Builds the layout of the widget. + """ + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.view) + self.setLayout(layout) + + def connect_signals(self): + """ + Connects signals to their respective slots. + """ + app.signals.metadata.synced.connect(self.syncLater) + app.signals.project.changed.connect(self.syncLater) + app.signals.meta.databases_changed.connect(self.syncLater) + app.signals.database.deleted.connect(self.syncLater) + + app.signals.parameter.changed.connect(self.syncLater) + app.signals.parameter.recalculated.connect(self.syncLater) + app.signals.parameter.deleted.connect(self.syncLater) + + def syncLater(self): + """ + Schedules a sync operation to be performed later. + """ + + def slot(): + self._populate_later_flag = False + self.sync() + self.thread().eventDispatcher().awake.disconnect(slot) + + if self._populate_later_flag: + return + + self._populate_later_flag = True + self.thread().eventDispatcher().awake.connect(slot) + + def sync(self): + """ + Synchronizes the widget with the current state of parameters. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + df = self.build_df() + self.model.set_dataframe(df, group=["_param_type", "_scope"]) + self.view.expandAll() + + self.view.resizeColumnToContents(1) + self.view.resizeColumnToContents(3) + self.view.resizeColumnToContents(4) + + def build_df(self) -> pd.DataFrame: + """ + Builds a DataFrame from all parameters in the project. + + Returns: + pd.DataFrame: The DataFrame containing the parameters data. + """ + translated = [] + + # Project parameters + for param in ProjectParameter.select(): + row = self._parameter_to_row(param) + translated.append(row) + + translated.append({ + "name": "New parameter...", + "_group": "project", + "_param_type": "project", + "_class": "new", + }) + + # Database parameters + db_params = DatabaseParameter.select() + for db_name in bd.databases.list: + + for param in db_params.where(DatabaseParameter.database == db_name): + row = self._parameter_to_row(param, db_name, db_name) + translated.append(row) + + if not database_is_locked(db_name): + translated.append({ + "name": "New parameter...", + "_scope": db_name, + "_database": db_name, + "_group": db_name, + "_param_type": "database", + "_class": "new", + }) + + # Activity parameters + act_params = ActivityParameter.select() + groups = Group.select() + non_act = ["project"] + bd.databases.list + + for group_name in [group.name for group in groups if group.name not in non_act]: + param = None + + for param in act_params.where(ActivityParameter.group == group_name): + row = self._parameter_to_row(param, f"Group: {group_name}", param.database) + translated.append(row) + + if param is None: + # No parameters in this group: broken group + translated.append({ + "name": "Broken parameter group", + "_scope": f"Group: {group_name}", + "_database": None, + "_group": group_name, + "_param_type": "activity", + "_class": "broken", + }) + continue + + if not database_is_locked(param.database): + translated.append({ + "name": "New parameter...", + "_scope": f"Group: {group_name}", + "_database": param.database, + "_group": group_name, + "_param_type": "activity", + "_class": "new", + }) + + columns = ["name", "amount", "formula", "uncertainty", "comment", "_parameter", "_scope", "_database", "_group", "_param_type", "_class"] + df = pd.DataFrame(translated, columns=columns) + return df + + def _parameter_to_row(self, param, scope_label: str = None, database: str = None) -> dict: + """ + Converts a parameter to a row dictionary. + + Args: + param: The parameter to convert (ProjectParameter, DatabaseParameter, or ActivityParameter). + scope_label: The label for the scope (e.g., "Current project", "Database: ecoinvent"). + database: The database name (None for project parameters). + + Returns: + dict: A dictionary representing the parameter row. + """ + data = param.dict + + # Create Parameter wrapper + if isinstance(param, ProjectParameter): + parameter = Parameter(param.name, "project", data.get("amount"), data, "project") + group = "project" + param_type = "project" + elif isinstance(param, DatabaseParameter): + parameter = Parameter(param.name, param.database, data.get("amount"), data, "database") + group = param.database + param_type = "database" + elif isinstance(param, ActivityParameter): + parameter = Parameter(param.name, param.group, data.get("amount"), data, "activity") + group = param.group + param_type = "activity" + else: + raise ValueError(f"Unknown parameter type: {type(param)}") + + row = { + "name": parameter.name, + "amount": parameter.amount, + "uncertainty": parameter.uncertainty, + "formula": data.get("formula"), + "comment": data.get("comment"), + "_param_type": param_type, + "_parameter": parameter, + "_scope": scope_label, + "_database": database, + "_group": group, + "_class": "instantiated", + } + + return row + + +class ProjectParametersView(widgets.ABTreeView): + """ + A view that displays the project parameters in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + """ + defaultColumnDelegates = { + "amount": delegates.FloatDelegate, + "name": delegates.StringDelegate, + "formula": delegates.NewFormulaDelegate, + "comment": delegates.StringDelegate, + "uncertainty": delegates.UncertaintyDelegate, + } + + class ContextMenu(widgets.ABMenu): + """ + A context menu for the ProjectParametersView. + """ + menuSetup = [ + lambda m, p: m.add(app.actions.ParameterDelete, p.selected_parameters(), + text="Delete parameter(s)", + enable=(all([p.deletable for p in p.selected_parameters()]) + and len(p.selected_parameters()) > 0) + and all([not database_is_locked(p.data['database']) + for p in p.selected_parameters() + if p.param_type != "project" + ]) + ), + lambda m, p: m.add(app.actions.ParameterGroupDelete, p.selected_groups(), + text="Delete parameter group(s)", + enable=(len(p.selected_groups()) > 0 + and all([g not in ["project"] + list(bd.databases) + for g in p.selected_groups()]) + and all([not database_is_locked(p.data['database']) + for p in p.selected_parameters() + if p.param_type != "project" + ]) + ) + ), + ] + + def selected_parameters(self): + """ + Returns a list of selected parameters in the view. + + Returns: + list: A list of selected Parameter objects. + """ + selected = [] + for index in self.selectedIndexes(): + parameter = self.model().get(index, "_parameter") + if parameter is not None and not pd.isna(parameter) and parameter not in selected: + selected.append(parameter) + + return selected + + def selected_groups(self): + """ + Returns a list of selected parameter groups in the view. + + Returns: + list: A list of selected parameter group names. + """ + selected = set() + for index in self.selectedIndexes(): + group = self.model().get(index, "_group") + group and selected.add(group) + + return list(selected) + +class ProjectParametersModel(core.ABTreeModel): + """ + A model representing the data for all project parameters. + """ + + def setData(self, index: QtCore.QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool: + """ + Sets the data for the given index. + + Args: + index (QtCore.QModelIndex): The index to set data for. + value: The value to set. + role (int): The role for which to set the data. + + Returns: + bool: True if the data was set successfully, False otherwise. + """ + if role != Qt.ItemDataRole.EditRole: + return False + + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return False + + # Handle "New parameter..." rows + if row.get("_class") == "new": + if column_name != "name" or value == "": + return False + + parameter = Parameter( + name=value, + group=row.get("_group"), + param_type=row.get("_param_type") + ) + + app.actions.ParameterNewFromParameter.run(parameter) + return True + + # Handle regular parameter edits + parameter = row.get("_parameter") + if parameter is None: + return False + + if column_name in ["amount", "formula", "name", "comment"]: + parameter = refresh_parameter(parameter) + app.actions.ParameterModify.run(parameter, column_name, value) + + if column_name == "uncertainty": + parameter = refresh_parameter(parameter) + app.actions.ParameterUncertaintyModify.run(parameter.to_peewee_model(), uncertainty_dict=value) + + return True + + return False + + def decorationData(self, index: QtCore.QModelIndex) -> any: + """ + Provides decoration data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide decoration data. + + Returns: + The decoration data for the index. + """ + column_name = self.column_name(index) + + if column_name == "amount": + formula = self.get(index, "formula") + formula = isinstance(formula, str) and formula.strip() + + return icons.qicons.parameterized if formula else icons.qicons.empty + + return None + + def fontData(self, index: QtCore.QModelIndex) -> any: + """ + Provides font data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide font data. + + Returns: + QtGui.QFont: The font data for the index. + """ + param_class = self.get(index, "_class") + if param_class == "new": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.ExtraLight) + return font + + if param_class == "broken": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.Bold) + return font + + return None + + def indexEditable(self, index: QtCore.QModelIndex) -> bool: + """ + Returns whether the index is editable. + + Args: + index (QtCore.QModelIndex): The index to check. + + Returns: + bool: True if the index is editable, False otherwise. + """ + column_name = self.column_name(index) + + # Check if database is locked + database = self.get(index, "_database") + if not pd.isna(database) and database_is_locked(database): + return False + + # Prevent editing broken parameters + if self.get(index, "_class") == "broken": + return False + + # Allow editing for specific columns + if column_name in ["formula", "uncertainty", "name", "comment"]: + return True + + if column_name == "amount" and not self.get(index, "formula"): + return True + + return False + + def scoped_parameters(self, index: QtCore.QModelIndex) -> dict[str, Parameter]: + """ + Returns the parameters in scope of the parameter at the given index. + + Args: + index (QtCore.QModelIndex): The index to get scoped parameters for. + + Returns: + dict: The parameters in scope. + """ + parameter = self.get(index, "_parameter") + if parameter is None or isinstance(parameter, float): # NaN check + return {} + + return parameters_in_scope(parameter=parameter) + diff --git a/activity_browser/app/pages/settings/README.md b/activity_browser/app/pages/settings/README.md new file mode 100644 index 000000000..830bf407f --- /dev/null +++ b/activity_browser/app/pages/settings/README.md @@ -0,0 +1,194 @@ +# Settings Module + +This module contains the settings page and its chapters. + +## Structure + +``` +settings/ +├── __init__.py # Module exports +├── settings_page.py # Main SettingsPage class +├── base.py # BaseSettingsChapter (base class for all chapters) +├── startup.py # StartupSettingsChapter +├── appearance.py # AppearanceSettingsChapter +├── project_manager.py # ProjectManagerSettingsChapter +├── metadatastore.py # MetadataStoreSettingsChapter +├── plugins.py # PluginsSettingsChapter +└── README.md # This file +``` + +## Adding a New Chapter + +### Step 1: Create a new chapter file + +Create a new file in this directory, e.g., `my_chapter.py`: + +```python +# -*- coding: utf-8 -*- +from loguru import logger +from qtpy import QtWidgets + +from activity_browser.settings import ab_settings +from .base import BaseSettingsChapter + + +class MySettingsChapter(BaseSettingsChapter): + """Chapter for my settings.""" + + def __init__(self, parent=None): + super().__init__(parent) + + # Create your widgets + self.my_widget = QtWidgets.QLineEdit() + + self.build_layout() + self.connect_signals() + + def connect_signals(self): + """Connect signals for change tracking.""" + self.my_widget.textChanged.connect(self.changed.emit) + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Create your UI + group = QtWidgets.QGroupBox("My Settings") + group_layout = QtWidgets.QGridLayout() + group_layout.addWidget(QtWidgets.QLabel("Setting:"), 0, 0) + group_layout.addWidget(self.my_widget, 0, 1) + group.setLayout(group_layout) + + layout.addWidget(group) + layout.addStretch() + + self.setLayout(layout) + + def get_current_state(self): + """Return current state for change tracking.""" + return { + 'my_setting': self.my_widget.text(), + } + + def save_settings(self): + """Save chapter-specific settings.""" + ab_settings.my_setting = self.my_widget.text() + logger.info("Saved my settings") + + def reset(self): + """Reset chapter to initial values.""" + self.my_widget.setText(ab_settings.my_setting) + + def restore_defaults(self): + """Restore default values.""" + self.my_widget.setText("default value") +``` + +### Step 2: Import in settings_page.py + +In `settings_page.py`, add your import: + +```python +from .my_chapter import MySettingsChapter +``` + +### Step 3: Add to chapters list + +In the `SettingsPage.__init__()` method, add your chapter: + +```python +# Create chapters +self.startup_chapter = StartupSettingsChapter(self) +self.appearance_chapter = AppearanceSettingsChapter(self) +self.my_chapter = MySettingsChapter(self) # <-- Add this + +# Add chapters to the stack +self.chapters = [ + ("Startup", self.startup_chapter), + ("Appearance", self.appearance_chapter), + ("My Chapter", self.my_chapter), # <-- And this +] +``` + +That's it! Your new chapter is now integrated. + +## BaseSettingsChapter Interface + +All chapters must inherit from `BaseSettingsChapter` and implement these methods: + +- **`get_current_state()`** - Return the current state as a dictionary for change tracking +- **`save_settings()`** - Save the chapter's settings to `ab_settings` +- **`reset()`** - Reset widgets to current `ab_settings` values +- **`restore_defaults()`** - Set widgets to default values + +### Change Tracking + +The base class automatically tracks changes using the `changed` signal: + +1. Override `get_current_state()` to return a dictionary of current values +2. Connect widget signals to `self.changed.emit()` to notify of changes +3. The save button will be enabled/disabled automatically based on changes + +Example: +```python +def __init__(self, parent=None): + super().__init__(parent) + self.my_widget = QtWidgets.QLineEdit() + self.build_layout() + self.connect_signals() + +def connect_signals(self): + # Emit changed signal when the widget changes + self.my_widget.textChanged.connect(self.changed.emit) + +def get_current_state(self): + """Return current state for change tracking.""" + return { + 'my_value': self.my_widget.text(), + } +``` + +## Existing Chapters + +### StartupSettingsChapter (`startup.py`) +Manages: +- Brightway directory selection and management +- Startup project selection +- Directory validation and project discovery + +### AppearanceSettingsChapter (`appearance.py`) +Manages: +- Theme selection (Light/Dark) +- Future: Font sizes, colors, etc. + +### ProjectManagerSettingsChapter (`project_manager.py`) +Manages: +- Project management settings +- Project creation and deletion + +### MetadataStoreSettingsChapter (`metadatastore.py`) +Manages: +- Metadata store caching settings +- Searcher configuration + +### PluginsSettingsChapter (`plugins.py`) +Manages: +- List of enabled Python plugins +- Add/remove plugin packages that should be imported at startup + +## Testing + +Test the settings page with: + +```bash +python test_settings_page.py +``` + +## Best Practices + +1. **Keep chapters focused** - Each chapter should handle a specific area of settings +2. **Use QGroupBox** - Organize widgets within chapters using group boxes +3. **Add tooltips** - Help users understand what each setting does +4. **Validate input** - Check settings before saving +5. **Log changes** - Use logger to record setting changes +6. **Handle errors gracefully** - Show appropriate error messages to users diff --git a/activity_browser/app/pages/settings/__init__.py b/activity_browser/app/pages/settings/__init__.py new file mode 100644 index 000000000..521c047d9 --- /dev/null +++ b/activity_browser/app/pages/settings/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from .settings_page import SettingsPage +from .base import BaseSettingsChapter +from .startup import StartupSettingsChapter +from .appearance import AppearanceSettingsChapter +from .project_manager import ProjectManagerSettingsChapter +from .metadatastore import MetadataStoreSettingsChapter +from .plugins import PluginsSettingsChapter + +__all__ = [ + "SettingsPage", + "BaseSettingsChapter", + "StartupSettingsChapter", + "AppearanceSettingsChapter", + "ProjectManagerSettingsChapter", + "MetadataStoreSettingsChapter", + "PluginsSettingsChapter", +] diff --git a/activity_browser/app/pages/settings/appearance.py b/activity_browser/app/pages/settings/appearance.py new file mode 100644 index 000000000..1ced98e4e --- /dev/null +++ b/activity_browser/app/pages/settings/appearance.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +from loguru import logger +from qtpy import QtWidgets + +from activity_browser.app import settings +from activity_browser.app.pages.settings.base import BaseSettingsChapter + + +class AppearanceSettingsChapter(BaseSettingsChapter): + """Chapter for appearance-related settings.""" + + theme_map = { + "default": "System default", + "light": "Light theme", + "dark": "Dark theme", + } + + pane_tab_position_map = { + "top": "Top", + "bottom": "Bottom", + "left": "Left", + "right": "Right", + } + + def __init__(self, parent=None): + super().__init__(parent) + + # Theme selector + self.theme_combo = QtWidgets.QComboBox() + + # Pane tab position selector + self.pane_tab_position_combo = QtWidgets.QComboBox() + + self.build_layout() + self.connect_signals() + self.reset() + + def connect_signals(self): + """Connect signals and slots.""" + # Emit changed signal when settings change + self.theme_combo.currentTextChanged.connect(lambda: self.changed.emit()) + self.pane_tab_position_combo.currentTextChanged.connect(lambda: self.changed.emit()) + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Theme section + theme_group = QtWidgets.QGroupBox("Theme") + theme_layout = QtWidgets.QGridLayout() + theme_layout.addWidget(QtWidgets.QLabel("Theme:"), 0, 0) + theme_layout.addWidget(self.theme_combo, 0, 1) + theme_group.setLayout(theme_layout) + + # Pane tab position section + pane_tab_group = QtWidgets.QGroupBox("Pane Tab Position") + pane_tab_layout = QtWidgets.QGridLayout() + pane_tab_layout.addWidget(QtWidgets.QLabel("Position:"), 0, 0) + pane_tab_layout.addWidget(self.pane_tab_position_combo, 0, 1) + pane_tab_group.setLayout(pane_tab_layout) + + layout.addWidget(theme_group) + layout.addWidget(pane_tab_group) + layout.addStretch() + + self.setLayout(layout) + + # --- Settings management methods --- # + def reset(self): + """(Re)set to initial values.""" + self.theme_combo.clear() + self.theme_combo.addItems(self.theme_map.values()) + self.theme_combo.setCurrentText(self.theme_map.get(settings["appearance"]["theme"], "System default")) + + self.pane_tab_position_combo.clear() + self.pane_tab_position_combo.addItems(self.pane_tab_position_map.values()) + self.pane_tab_position_combo.setCurrentText(self.pane_tab_position_map.get(settings["appearance"]["pane_tab_position"], "Bottom")) + + def has_changes(self): + """Check if there are unsaved changes.""" + current_state = { + 'theme': self.theme_combo.currentText(), + 'pane_tab_position': self.pane_tab_position_combo.currentText(), + } + initial_state = { + 'theme': self.theme_map.get(settings["appearance"]["theme"], "System default"), + 'pane_tab_position': self.pane_tab_position_map.get(settings["appearance"]["pane_tab_position"], "Bottom"), + } + return current_state != initial_state + + def set_settings(self): + """Save appearance settings.""" + new_theme = self.theme_combo.currentText() + settings["appearance"]["theme"] = [key for key, value in self.theme_map.items() if value == new_theme][0] + + new_pane_position = self.pane_tab_position_combo.currentText() + settings["appearance"]["pane_tab_position"] = [key for key, value in self.pane_tab_position_map.items() if value == new_pane_position][0] + diff --git a/activity_browser/app/pages/settings/base.py b/activity_browser/app/pages/settings/base.py new file mode 100644 index 000000000..9cd455f02 --- /dev/null +++ b/activity_browser/app/pages/settings/base.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from qtpy import QtCore, QtWidgets + + +class BaseSettingsChapter(QtWidgets.QWidget): + """Base class for settings chapters.""" + + # Signal emitted when settings change + changed = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.settings_page = parent + self._initial_state = None + + def get_current_state(self): + """ + Override this to return the current state of the chapter. + Should return a dictionary or tuple representing current values. + """ + return {} + + def has_changes(self): + """Check if the chapter has unsaved changes.""" + if self._initial_state is None: + return False + return self.get_current_state() != self._initial_state + + def save_settings(self): + """Override this to save chapter-specific settings.""" + pass + + def reset(self): + """Override this to reset chapter to initial values.""" + pass + + def restore_defaults(self): + """Override this to restore default values.""" + pass diff --git a/activity_browser/app/pages/settings/metadatastore.py b/activity_browser/app/pages/settings/metadatastore.py new file mode 100644 index 000000000..7659d4239 --- /dev/null +++ b/activity_browser/app/pages/settings/metadatastore.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +from loguru import logger +from qtpy import QtWidgets + +from activity_browser.app import settings +from activity_browser.app.actions.metadatastore_cache_clear import MetaDataStoreCacheClear +from activity_browser.app.pages.settings.base import BaseSettingsChapter + + +class MetadataStoreSettingsChapter(BaseSettingsChapter): + """Chapter for metadatastore-related settings.""" + + def __init__(self, parent=None): + super().__init__(parent) + + # Caching enabled checkbox + self.caching_checkbox = QtWidgets.QCheckBox("Enable caching") + self.caching_checkbox.setToolTip( + "Enable caching for faster data access. " + "Disable if you experience memory issues or want to force fresh data loading." + ) + + # Searcher enabled checkbox + self.searcher_checkbox = QtWidgets.QCheckBox("Enable searcher") + self.searcher_checkbox.setToolTip( + "Enable the full-text search functionality for activities and metadata. " + "Disable if you experience performance issues with large databases." + ) + + # Clear cache button + self.clear_cache_button = QtWidgets.QPushButton("Clear Cache") + self.clear_cache_button.setToolTip( + "Clear the metadata store cache and reload the current project. " + "Use this if you experience issues with outdated or corrupted cache data." + ) + + self.build_layout() + self.connect_signals() + self.reset() + + def connect_signals(self): + """Connect signals and slots.""" + # Emit changed signal when settings change + self.caching_checkbox.stateChanged.connect(lambda: self.changed.emit()) + self.searcher_checkbox.stateChanged.connect(lambda: self.changed.emit()) + + # Connect clear cache button + self.clear_cache_button.clicked.connect(MetaDataStoreCacheClear.run) + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Metadata store group + metadatastore_group = QtWidgets.QGroupBox("Metadata Store Options") + metadatastore_layout = QtWidgets.QVBoxLayout() + + metadatastore_layout.addWidget(self.caching_checkbox) + metadatastore_layout.addWidget(self.searcher_checkbox) + + # Add clear cache button + metadatastore_layout.addWidget(self.clear_cache_button) + + # Add description label + description = QtWidgets.QLabel( + "These settings control the behavior of the metadata store, " + "which manages activity data for improved performance." + ) + description.setWordWrap(True) + description.setStyleSheet("color: gray; font-size: 10pt;") + metadatastore_layout.addWidget(description) + + metadatastore_group.setLayout(metadatastore_layout) + + layout.addWidget(metadatastore_group) + layout.addStretch() + + self.setLayout(layout) + + # --- Settings management methods --- # + def reset(self): + """(Re)set to initial values.""" + try: + self.caching_checkbox.setChecked( + settings["metadatastore"]["caching_enabled"] + ) + self.searcher_checkbox.setChecked( + settings["metadatastore"]["searcher_enabled"] + ) + except (KeyError, TypeError): + # Use defaults if settings don't exist yet + self.caching_checkbox.setChecked(True) + self.searcher_checkbox.setChecked(True) + + def has_changes(self): + """Check if there are unsaved changes.""" + try: + current_state = { + 'caching_enabled': self.caching_checkbox.isChecked(), + 'searcher_enabled': self.searcher_checkbox.isChecked(), + } + initial_state = { + 'caching_enabled': settings["metadatastore"]["caching_enabled"], + 'searcher_enabled': settings["metadatastore"]["searcher_enabled"], + } + return current_state != initial_state + except (KeyError, TypeError): + # If settings don't exist, check against defaults + return (self.caching_checkbox.isChecked() != True or + self.searcher_checkbox.isChecked() != True) + + def set_settings(self): + """Save metadatastore settings.""" + if "metadatastore" not in settings.global_config: + settings.global_config["metadatastore"] = {} + + settings.global_config["metadatastore"]["caching_enabled"] = self.caching_checkbox.isChecked() + settings.global_config["metadatastore"]["searcher_enabled"] = self.searcher_checkbox.isChecked() + + logger.info( + f"Metadatastore settings saved: " + f"caching={self.caching_checkbox.isChecked()}, " + f"searcher={self.searcher_checkbox.isChecked()}" + ) + diff --git a/activity_browser/app/pages/settings/plugins.py b/activity_browser/app/pages/settings/plugins.py new file mode 100644 index 000000000..bd743a624 --- /dev/null +++ b/activity_browser/app/pages/settings/plugins.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +import importlib.util +from loguru import logger +from qtpy import QtWidgets + +from activity_browser.app import settings +from activity_browser.app.pages.settings.base import BaseSettingsChapter +from activity_browser.ui.icons import qicons + + +class PluginsSettingsChapter(BaseSettingsChapter): + """Chapter for plugin-related settings.""" + + def __init__(self, parent=None): + super().__init__(parent) + + # List widget to display enabled plugins + self.plugin_list = QtWidgets.QListWidget() + self.plugin_list.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + + # Input field for adding new plugins + self.plugin_input = QtWidgets.QLineEdit() + self.plugin_input.setPlaceholderText("Enter Python package name (e.g., my_plugin)") + + # Buttons + self.add_button = QtWidgets.QPushButton("Add") + self.remove_button = QtWidgets.QPushButton("Remove") + self.remove_button.setEnabled(False) + + self.build_layout() + self.connect_signals() + self.reset() + + def connect_signals(self): + """Connect signals and slots.""" + self.add_button.clicked.connect(self.add_plugin) + self.remove_button.clicked.connect(self.remove_plugin) + self.plugin_input.returnPressed.connect(self.add_plugin) + self.plugin_list.itemSelectionChanged.connect(self.on_selection_changed) + self.plugin_list.model().rowsInserted.connect(lambda: self.changed.emit()) + self.plugin_list.model().rowsRemoved.connect(lambda: self.changed.emit()) + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Plugin list section + plugin_group = QtWidgets.QGroupBox("Enabled Plugins") + plugin_layout = QtWidgets.QVBoxLayout() + + # Description label + description = QtWidgets.QLabel( + "Add Python packages that should be imported as plugins.\n" + "These packages will be loaded when Activity Browser starts." + ) + description.setWordWrap(True) + plugin_layout.addWidget(description) + + # List widget + plugin_layout.addWidget(self.plugin_list) + + # Input section + input_layout = QtWidgets.QHBoxLayout() + input_layout.addWidget(self.plugin_input) + input_layout.addWidget(self.add_button) + plugin_layout.addLayout(input_layout) + + # Remove button + plugin_layout.addWidget(self.remove_button) + + plugin_group.setLayout(plugin_layout) + + layout.addWidget(plugin_group) + layout.addStretch() + + self.setLayout(layout) + + def on_selection_changed(self): + """Enable/disable remove button based on selection.""" + self.remove_button.setEnabled(len(self.plugin_list.selectedItems()) > 0) + + def module_exists(self, module_name): + """Check if a module can be found/imported.""" + try: + spec = importlib.util.find_spec(module_name) + return spec is not None + except (ImportError, ModuleNotFoundError, ValueError, AttributeError): + return False + + def add_plugin_to_list(self, plugin_name): + """Add a plugin to the list widget with appropriate icon.""" + item = QtWidgets.QListWidgetItem(plugin_name) + + # Check if module exists and add warning icon if not + if not self.module_exists(plugin_name): + # Use standard warning icon + icon = qicons.critical + item.setIcon(icon) + item.setToolTip(f"Warning: Module '{plugin_name}' not found. " + "Make sure it is installed before starting Activity Browser.") + logger.warning(f"Plugin module '{plugin_name}' not found") + else: + icon = qicons.empty + item.setIcon(icon) + item.setToolTip(f"Module '{plugin_name}' is available") + + self.plugin_list.addItem(item) + + def add_plugin(self): + """Add a plugin to the list.""" + plugin_name = self.plugin_input.text().strip() + if not plugin_name: + return + + # Check if plugin already exists + existing_items = [self.plugin_list.item(i).text() for i in range(self.plugin_list.count())] + if plugin_name in existing_items: + QtWidgets.QMessageBox.warning( + self, + "Duplicate Plugin", + f"The plugin '{plugin_name}' is already in the list." + ) + return + + # Add to list with icon + self.add_plugin_to_list(plugin_name) + self.plugin_input.clear() + logger.debug(f"Added plugin: {plugin_name}") + self.changed.emit() + + def remove_plugin(self): + """Remove selected plugin from the list.""" + selected_items = self.plugin_list.selectedItems() + if not selected_items: + return + + for item in selected_items: + plugin_name = item.text() + row = self.plugin_list.row(item) + self.plugin_list.takeItem(row) + logger.debug(f"Removed plugin: {plugin_name}") + + self.changed.emit() + + # --- Settings management methods --- # + def reset(self): + """(Re)set to initial values.""" + self.plugin_list.clear() + enabled_plugins = settings["plugins"].get("enabled_plugins", []) + for plugin in enabled_plugins: + self.add_plugin_to_list(plugin) + self.plugin_input.clear() + self.remove_button.setEnabled(False) + + def has_changes(self): + """Check if there are unsaved changes.""" + current_plugins = [self.plugin_list.item(i).text() for i in range(self.plugin_list.count())] + saved_plugins = settings["plugins"].get("enabled_plugins", []) + return current_plugins != saved_plugins + + def set_settings(self): + """Save plugin settings.""" + current_plugins = [self.plugin_list.item(i).text() for i in range(self.plugin_list.count())] + settings["plugins"]["enabled_plugins"] = current_plugins + logger.info(f"Saved enabled plugins: {current_plugins}") + diff --git a/activity_browser/app/pages/settings/project_manager.py b/activity_browser/app/pages/settings/project_manager.py new file mode 100644 index 000000000..925550825 --- /dev/null +++ b/activity_browser/app/pages/settings/project_manager.py @@ -0,0 +1,227 @@ +from loguru import logger + +import pandas as pd +from qtpy import QtWidgets, QtGui + +import bw2data as bd +from bw2io import remote + +from activity_browser import app, ui +from activity_browser.bwutils.commontasks import get_templates +from activity_browser.ui import widgets, core + +from .base import BaseSettingsChapter + + +class ProjectManagerSettingsChapter(BaseSettingsChapter): + """Chapter for project and template management.""" + + def __init__(self, parent=None): + super().__init__(parent) + + self.tabs = QtWidgets.QTabWidget(self) + + self.project_model = ProjectModel(parent=self, enable_sorting=True) + self.template_model = TemplateModel(parent=self, enable_sorting=True) + + self.project_view = ProjectView(self) + self.project_view.setModel(self.project_model) + + self.template_view = TemplateView(self) + self.template_view.setModel(self.template_model) + + self.tabs.addTab(self.project_view, "Projects") + self.tabs.addTab(self.template_view, "Templates") + + self.build_layout() + self.connect_signals() + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.tabs) + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + def connect_signals(self): + """Connect signals and slots.""" + app.signals.project.deleted.connect(self.sync) + + def sync(self): + """Sync project and template data.""" + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + df = self.build_project_df() + self.project_model.set_dataframe(df) + self.project_view.resizeColumnToContents(1) + + df = self.build_template_df() + self.template_model.set_dataframe(df) + self.template_view.resizeColumnToContents(1) + + def reset(self): + """Reset to initial values.""" + self.sync() + + def has_changes(self): + """Project manager doesn't have editable settings.""" + return False + + def set_settings(self): + """No settings to save for project manager.""" + pass + + def build_project_df(self) -> pd.DataFrame: + """Build DataFrame for projects.""" + data = [] + for proj_ds in sorted(bd.projects): + # if for any reason the project data is not a dictionary, log a warning and set it to an empty dict + if not isinstance(proj_ds.data, dict): + logger.warning(f"Project {proj_ds.name} has no data dictionary") + proj_ds.data = {} + + data.append({ + "name": proj_ds.name, + "path": proj_ds.dir, + "version": "Brightway25" if proj_ds.data.get("25", False) else "Legacy" + }) + + cols = ["name", "version", "path"] + return pd.DataFrame(data, columns=cols) + + def build_template_df(self) -> pd.DataFrame: + """Build DataFrame for templates.""" + data = [] + + templates = get_templates() + remote_templates = remote.get_projects() + + for name in sorted(templates): + data.append({ + "name": name, + "path": templates[name], + "remote": "No" + }) + + for name in sorted(remote_templates): + data.append({ + "name": name, + "path": remote_templates[name], + "remote": "Yes" + }) + + cols = ["name", "path", "remote"] + return pd.DataFrame(data, columns=cols) + + +class ProjectView(widgets.ABTreeView): + + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m, p: m.addMenu(p.get_project_new_menu(m)), + lambda m, p: m.addSeparator() if p.has_selection else None, + lambda m, p: m.add(app.actions.ProjectDuplicate, p.selected_project, + enable=p.single_selection) if p.single_selection else None, + lambda m, p: m.add(app.actions.ProjectCreateTemplate, p.selected_project, m.parent(), + enable=p.single_selection) if p.single_selection else None, + lambda m, p: m.add(app.actions.ProjectMigrate25, p.selected_project, + enable=(p.single_selection and p.is_legacy)) if p.single_selection and p.is_legacy else None, + lambda m, p: m.addSeparator() if p.has_selection else None, + lambda m, p: m.add(app.actions.ProjectDelete, p.selected_projects, + enable=p.has_selection) if p.has_selection else None, + ] + + def __init__(self, parent): + super().__init__(parent) + self.setSortingEnabled(True) + self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) + + def get_project_new_menu(self, parent): + """Get the ProjectNewMenu.""" + from activity_browser.app.menu_bar import ProjectNewMenu + return ProjectNewMenu(parent) + + @property + def selected_projects(self) -> list: + if not self.selectedIndexes(): + return [] + names = self.model().values_from_indices("name", self.selectedIndexes()) + return list(set(names)) + + @property + def selected_project(self): + return self.selected_projects[0] if self.single_selection else None + + @property + def single_selection(self): + return len(self.selected_projects) == 1 + + @property + def has_selection(self): + return len(self.selected_projects) > 0 + + @property + def is_legacy(self): + if not self.single_selection: + return False + index = self.selectedIndexes()[0] + return self.model().get(index, "version") == "Legacy" + + +class ProjectModel(core.ABTreeModel): + """Model for project data.""" + + def fontData(self, index): + """Provide font data for the model.""" + column_name = self.column_name(index) + + if column_name == "name": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font + + return None + + def decorationData(self, index): + """Provide icon decoration for the model.""" + column_name = self.column_name(index) + + if column_name == "name": + name = self.get(index, "name") + if name == app.settings["startup"]["startup_project"]: + return ui.icons.qicons.star + if name == bd.projects.current: + return ui.icons.qicons.forward + + return ui.icons.qicons.empty + + return None + + +class TemplateView(widgets.ABTreeView): + + class ContextMenu(widgets.ABMenu): + menuSetup = [] + + def __init__(self, parent): + super().__init__(parent) + self.setSortingEnabled(True) + self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + + +class TemplateModel(core.ABTreeModel): + """Model for template data.""" + + def fontData(self, index): + """Provide font data for the model.""" + column_name = self.column_name(index) + + if column_name == "name": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font + + return None + + diff --git a/activity_browser/app/pages/settings/settings_page.py b/activity_browser/app/pages/settings/settings_page.py new file mode 100644 index 000000000..ffbe139c3 --- /dev/null +++ b/activity_browser/app/pages/settings/settings_page.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +from loguru import logger +from pathlib import Path + +from qtpy import QtWidgets + +from bw2data import projects + +from activity_browser.app import settings, signals + +from .startup import StartupSettingsChapter +from .appearance import AppearanceSettingsChapter +from .project_manager import ProjectManagerSettingsChapter +from .metadatastore import MetadataStoreSettingsChapter +from .plugins import PluginsSettingsChapter + + +class SettingsPage(QtWidgets.QWidget): + """Settings page with a sidebar navigation for different settings chapters.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("SettingsPage") + + # Store initial state for cancel functionality + self.last_project = projects.current + self.last_bwdir = projects._base_data_dir + + # Chapter list (sidebar) + self.chapter_list = QtWidgets.QListWidget() + self.chapter_list.setMaximumWidth(200) + self.chapter_list.setMinimumWidth(100) + self.chapter_list.setSpacing(2) + + # Stacked widget for chapter content + self.content_stack = QtWidgets.QStackedWidget() + + # Create chapters + self.startup_chapter = StartupSettingsChapter(self) + self.appearance_chapter = AppearanceSettingsChapter(self) + self.project_manager_chapter = ProjectManagerSettingsChapter(self) + self.metadatastore_chapter = MetadataStoreSettingsChapter(self) + self.plugins_chapter = PluginsSettingsChapter(self) + + # Add chapters to the stack + self.chapters = [ + ("Startup", self.startup_chapter), + ("Appearance", self.appearance_chapter), + ("Projects", self.project_manager_chapter), + ("Metadata Store", self.metadatastore_chapter), + ("Plugins", self.plugins_chapter), + ] + + for name, widget in self.chapters: + self.chapter_list.addItem(name) + self.content_stack.addWidget(widget) + + # Select first chapter by default + self.chapter_list.setCurrentRow(0) + + # Buttons + self.button_layout = QtWidgets.QHBoxLayout() + self.save_button = QtWidgets.QPushButton("Save") + self.cancel_button = QtWidgets.QPushButton("Cancel") + self.restore_defaults_button = QtWidgets.QPushButton("Restore Defaults") + + self.button_layout.addWidget(self.restore_defaults_button) + self.button_layout.addStretch() + self.button_layout.addWidget(self.cancel_button) + self.button_layout.addWidget(self.save_button) + + # Build layout + self.build_layout() + self.connect_signals() + + # Store initial state and disable save button initially + self.save_button.setEnabled(False) + + def build_layout(self): + """Build the main layout with sidebar and content area.""" + # Main content area with sidebar and content + content_layout = QtWidgets.QHBoxLayout() + content_layout.addWidget(self.chapter_list) + + # Add vertical separator + separator = QtWidgets.QFrame() + separator.setFrameShape(QtWidgets.QFrame.VLine) + separator.setFrameShadow(QtWidgets.QFrame.Sunken) + content_layout.addWidget(separator) + + content_layout.addWidget(self.content_stack, 1) + + # Main layout + main_layout = QtWidgets.QVBoxLayout() + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.addLayout(content_layout, 1) + main_layout.addLayout(self.button_layout) + + self.setLayout(main_layout) + + # Set minimum size for resizability + self.setMinimumSize(400, 300) + + def connect_signals(self): + """Connect signals and slots.""" + signals.project.changed.connect(self.reset_all) + + self.chapter_list.currentRowChanged.connect(self.content_stack.setCurrentIndex) + self.save_button.clicked.connect(self.save_settings) + self.cancel_button.clicked.connect(self.cancel_settings) + self.restore_defaults_button.clicked.connect(self.restore_defaults) + + # Connect change signals from each chapter + for name, chapter in self.chapters: + if hasattr(chapter, 'changed'): + chapter.changed.connect(self.on_chapter_changed) + + def on_chapter_changed(self): + """Called when any chapter's settings change.""" + has_changes = self.has_changes() + self.save_button.setEnabled(has_changes) + + def has_changes(self): + """Check if any chapter has unsaved changes.""" + for name, chapter in self.chapters: + if hasattr(chapter, 'has_changes') and chapter.has_changes(): + return True + return False + + def save_settings(self): + """Save all settings from all chapters.""" + for name, chapter in self.chapters: + if hasattr(chapter, 'set_settings'): + chapter.set_settings() + + settings.save() + logger.info("Settings saved successfully") + + # Reset all chapters to the new saved state + self.reset_all() + + def cancel_settings(self): + """Cancel changes and revert to previous state.""" + logger.info("Cancelling settings changes") + self.reset_all() + + def restore_defaults(self): + """Restore default settings for the current chapter.""" + logger.info("Restoring default settings") + settings.restore_defaults() + self.reset_all() + + def reset_all(self): + """Reset all chapters to their initial states.""" + for name, chapter in self.chapters: + if hasattr(chapter, 'reset'): + chapter.reset() + self.save_button.setEnabled(False) + diff --git a/activity_browser/app/pages/settings/startup.py b/activity_browser/app/pages/settings/startup.py new file mode 100644 index 000000000..aa5b3c7f6 --- /dev/null +++ b/activity_browser/app/pages/settings/startup.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +import os +from loguru import logger +from pathlib import Path + +from peewee import SqliteDatabase, OperationalError +from qtpy import QtCore, QtWidgets + +from bw2data import projects + +from activity_browser.app import settings, panes, pages +from .base import BaseSettingsChapter + + +class StartupSettingsChapter(BaseSettingsChapter): + """Chapter for startup-related settings.""" + + def __init__(self, parent=None): + super().__init__(parent) + + # Brightway directory + self.bwdir_combo = QtWidgets.QComboBox() + self.bwdir_browse_button = QtWidgets.QPushButton("Browse") + self.bwdir_remove_button = QtWidgets.QPushButton("Remove") + + # Startup project + self.startup_project_combo = QtWidgets.QComboBox() + + # Shown panes checkboxes + self.pane_checkboxes = {} + self.available_panes = list(panes.base_panes.keys()) + for pane_name in self.available_panes: + self.pane_checkboxes[pane_name] = QtWidgets.QCheckBox(pane_name) + + # Shown pages checkboxes + self.page_checkboxes = {} + self.available_pages = list(pages.base_pages.keys()) + for page_name in self.available_pages: + self.page_checkboxes[page_name] = QtWidgets.QCheckBox(page_name) + + self.build_layout() + self.connect_signals() + self.reset() + + def build_layout(self): + """Build the chapter layout.""" + layout = QtWidgets.QVBoxLayout() + + # Brightway directory section + bwdir_group = QtWidgets.QGroupBox("Brightway Directory") + bwdir_layout = QtWidgets.QGridLayout() + bwdir_layout.addWidget(QtWidgets.QLabel("Directory:"), 0, 0) + bwdir_layout.addWidget(self.bwdir_combo, 0, 1) + bwdir_layout.addWidget(self.bwdir_browse_button, 0, 2) + bwdir_layout.addWidget(self.bwdir_remove_button, 0, 3) + bwdir_group.setLayout(bwdir_layout) + + # Startup project section + project_group = QtWidgets.QGroupBox("Startup Project") + project_layout = QtWidgets.QGridLayout() + project_layout.addWidget(QtWidgets.QLabel("Project:"), 0, 0) + project_layout.addWidget(self.startup_project_combo, 0, 1) + project_group.setLayout(project_layout) + + # Shown panes section + panes_group = QtWidgets.QGroupBox("Panes shown at startup") + panes_layout = QtWidgets.QVBoxLayout() + for pane_name in self.available_panes: + panes_layout.addWidget(self.pane_checkboxes[pane_name]) + panes_group.setLayout(panes_layout) + + # Shown pages section + pages_group = QtWidgets.QGroupBox("Pages shown at startup") + pages_layout = QtWidgets.QVBoxLayout() + for page_name in self.available_pages: + pages_layout.addWidget(self.page_checkboxes[page_name]) + pages_group.setLayout(pages_layout) + + layout.addWidget(bwdir_group) + layout.addWidget(project_group) + layout.addWidget(panes_group) + layout.addWidget(pages_group) + layout.addStretch() + + self.setLayout(layout) + + def connect_signals(self): + """Connect signals and slots.""" + self.bwdir_browse_button.clicked.connect(self.browse_bwdir) + self.bwdir_remove_button.clicked.connect(self.remove_bwdir) + + # Emit changed signal when settings change + self.bwdir_combo.currentTextChanged.connect(lambda: self.changed.emit()) + self.bwdir_combo.currentTextChanged.connect(self.show_virtual_projects) + self.startup_project_combo.currentTextChanged.connect(lambda: self.changed.emit()) + + # Connect checkboxes + for checkbox in self.pane_checkboxes.values(): + checkbox.stateChanged.connect(lambda: self.changed.emit()) + for checkbox in self.page_checkboxes.values(): + checkbox.stateChanged.connect(lambda: self.changed.emit()) + + # --- Settings management methods --- # + def reset(self): + """(Re)set to initial values.""" + self.bwdir_combo.clear() + self.bwdir_combo.addItems(settings["startup"].get("saved_brightway_directories", [])) + self.bwdir_combo.setCurrentText(settings["startup"]["brightway_directory"]) + + self.startup_project_combo.clear() + self.startup_project_combo.addItems(self.get_projects_from_path(settings["startup"]["brightway_directory"])) + self.startup_project_combo.setCurrentText(settings["startup"]["startup_project"]) + + # Set pane checkboxes + shown_panes = settings["startup"].get("shown_panes", []) + for pane_name, checkbox in self.pane_checkboxes.items(): + checkbox.setChecked(pane_name in shown_panes) + + # Set page checkboxes + shown_pages = settings["startup"].get("shown_pages", []) + for page_name, checkbox in self.page_checkboxes.items(): + checkbox.setChecked(page_name in shown_pages) + + def has_changes(self): + """Check if there are unsaved changes.""" + current_state = { + 'brightway_directory': self.bwdir_combo.currentText(), + 'saved_brightway_directories': [self.bwdir_combo.itemText(i) for i in range(self.bwdir_combo.count())], + 'startup_project': self.startup_project_combo.currentText(), + 'shown_panes': [name for name, cb in self.pane_checkboxes.items() if cb.isChecked()], + 'shown_pages': [name for name, cb in self.page_checkboxes.items() if cb.isChecked()], + } + initial_state = { + 'brightway_directory': settings["startup"]["brightway_directory"], + 'saved_brightway_directories': settings["startup"].get("saved_brightway_directories", []), + 'startup_project': settings["startup"]["startup_project"], + 'shown_panes': settings["startup"].get("shown_panes", []), + 'shown_pages': settings["startup"].get("shown_pages", []), + } + return current_state != initial_state + + def set_settings(self): + """Save startup settings.""" + + settings["startup"]["brightway_directory"] = self.bwdir_combo.currentText() + settings["startup"]["saved_brightway_directories"] = [self.bwdir_combo.itemText(i) for i in range(self.bwdir_combo.count())] + settings["startup"]["startup_project"] = self.startup_project_combo.currentText() + + # Save shown panes and pages + settings["startup"]["shown_panes"] = [name for name, cb in self.pane_checkboxes.items() if cb.isChecked()] + settings["startup"]["shown_pages"] = [name for name, cb in self.page_checkboxes.items() if cb.isChecked()] + + # --- Helper methods --- # + def browse_bwdir(self): + """Browse for a brightway directory.""" + path = Path(QtWidgets.QFileDialog.getExistingDirectory( + self, "Select a brightway2 database folder" + )) + if not path: + return + + if (path / "projects.db").is_file(): + self.bwdir_combo.addItem(str(path)) + self.bwdir_combo.setCurrentText(str(path)) + return + + reply = QtWidgets.QMessageBox.question( + self, + "New brightway data directory?", + 'This directory does not contain any projects. Switching to this directory will create a new brightway2 data folder here.', + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel, + ) + + if reply == QtWidgets.QMessageBox.Cancel: + return + + self.bwdir_combo.addItem(str(path)) + self.bwdir_combo.setCurrentText(str(path)) + + def remove_bwdir(self): + """Remove the selected brightway directory from the list.""" + reply = QtWidgets.QMessageBox.question( + self, + "Delete Brightway2 directory?", + "This action will remove the local information only, click 'Yes' to remove\n" + "the projects. Data on the 'disk' will remain untouched and needs to be removed manually", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel, + ) + if reply == QtWidgets.QMessageBox.Cancel: + return + + removed_index = self.bwdir_combo.currentIndex() + self.bwdir_combo.setCurrentText(settings["startup"]["brightway_directory"]) + self.bwdir_combo.removeItem(removed_index) + + def show_virtual_projects(self): + """Show projects from the virtual Brightway directory.""" + virtual_projects = self.get_projects_from_path(self.bwdir_combo.currentText()) + startup = settings["startup"]["startup_project"] + + self.startup_project_combo.clear() + self.startup_project_combo.addItems(virtual_projects if virtual_projects else ["default"]) + self.startup_project_combo.setCurrentText(startup if startup in virtual_projects else "default") + + def get_projects_from_path(self, path: str): + """Get project names from a brightway directory.""" + database_file = os.path.join(path, "projects.db") + if not os.path.exists(database_file): + return [] + db = SqliteDatabase(database_file) + + try: + cursor = db.execute_sql('SELECT "name" FROM "projectdataset"') + except OperationalError as e: + if "no such table" in str(e): + return [] + raise + return [i[0] for i in cursor.fetchall()] diff --git a/activity_browser/app/pages/welcome.py b/activity_browser/app/pages/welcome.py new file mode 100644 index 000000000..25ef4fb65 --- /dev/null +++ b/activity_browser/app/pages/welcome.py @@ -0,0 +1,75 @@ +import os + +from qtpy import QtWebEngineWidgets, QtWidgets, QtCore, QtGui, QtWebChannel + +from activity_browser import app, app +from activity_browser.static import startscreen +from activity_browser.bwutils.commontasks import projects_by_last_opened + + +class WelcomePage(QtWidgets.QWidget): + html_file = os.path.join(startscreen.__path__[0], "welcome.html") + + def __init__(self, parent=None): + super().__init__(parent) + self.view = QtWebEngineWidgets.QWebEngineView() + self.page = WelcomeWebPage() + self.channel = QtWebChannel.QWebChannel(self) + self.bridge = Bridge(self) + self.channel.registerObject("bridge", self.bridge) + + self.url = QtCore.QUrl.fromLocalFile(self.html_file) + self.page.setWebChannel(self.channel) + self.page.load(self.url) + + # associate page with view + self.view.setPage(self.page) + + # set layout + self.vl = QtWidgets.QVBoxLayout() + self.vl.addWidget(self.view) + self.setLayout(self.vl) + + self.bridge.ready.connect(self.update_welcome) + app.signals.project.changed.connect(lambda: self.page.load(self.url)) + + def update_welcome(self): + projects = projects_by_last_opened() + projects = projects[1:5] if len(projects) > 5 else projects[1:] + project_names = [p.name for p in projects] + self.bridge.update.emit(project_names) + + +class Bridge(QtCore.QObject): + """ + A bridge for communication between Python and JavaScript. + + Attributes: + update_graph (SignalInstance): A signal to update the graph. + ready (SignalInstance): A signal indicating that the bridge is ready. + """ + update: QtCore.SignalInstance = QtCore.Signal(list) + ready: QtCore.SignalInstance = QtCore.Signal() + + @QtCore.Slot() + def is_ready(self): + """ + Emits the ready signal. + """ + self.ready.emit() + + @QtCore.Slot(str) + def open_project(self, project_name): + """ + Emits the ready signal. + """ + app.actions.ProjectSwitch.run(project_name) + +class WelcomeWebPage(QtWebEngineWidgets.QWebEnginePage): + def acceptNavigationRequest(self, qurl, navtype, mainframe): + # print("Navigation Request intercepted:", qurl) + if qurl.isLocalFile(): # open in Activity Browser QWebEngineView + return True + else: # delegate link to default browser + QtGui.QDesktopServices.openUrl(qurl) + return False diff --git a/activity_browser/app/panes/README.md b/activity_browser/app/panes/README.md new file mode 100644 index 000000000..6b6eb305c --- /dev/null +++ b/activity_browser/app/panes/README.md @@ -0,0 +1,75 @@ +# panes + +Dock-able side panels that can be arranged around the main content area. + +## Overview + +This directory contains pane widgets that can be docked to the edges of the main window or floated as separate windows. Panes provide quick access to navigation, information, and tools while working with the main content pages. + +## Purpose + +Panes offer: +- **Quick navigation** - Browse databases, activities, methods +- **Contextual information** - Show details about selected items +- **Tool access** - Quick access to common tools and operations +- **Workspace customization** - Users can arrange panes to suit their workflow + +## Pane Architecture + +Panes inherit from `AbstractPane` (in `ui/widgets/abstract_pane.py`) which provides: +- Dock widget functionality +- Consistent styling +- Signal connections +- State persistence (dock position, visibility) + +## Existing Panes +- **Databases Pane** - View of available databases +- **Database Products Pane** - Search and browse product-type nodes within a database +- **Impact Categories Pane** - Browse impact assessment methods +- **Calculation Setups Pane** - List of Calculation Setups + +## Pane Features + +### Docking Behavior +Panes can be: +- Docked to window edges (left, right, top, bottom) +- Stacked with other panes (tabbed) +- Floated as separate windows +- Resized by dragging dividers +- Hidden/shown via View menu + +### State Persistence +Pane positions and visibility are saved between sessions: +- Dock area and position +- Floating window geometry +- Visibility state +- Tab order when stacked + +## Usage Pattern + +```python +from activity_browser.ui.widgets import AbstractPane + +class MyPane(AbstractPane): + def __init__(self, parent=None): + super().__init__(parent) +``` + +## Development Guidelines + +When creating new panes: + +- **Inherit from AbstractPane** - Use the base class for consistency +- **Set pane title** - Use the standard `PaneNamePane` naming convention to set the title automatically +- **Base panes** - Add base panes to `__init__.py` in this directory so they are loaded by the main window on project change. + + +## Visibility Control + +Panes can be shown/hidden via: +- View menu (one menu item per pane) +- Toolbar buttons +- Keyboard shortcuts +- Context menu on title bar + +The main window tracks pane visibility and provides a centralized way to manage them. diff --git a/activity_browser/app/panes/__init__.py b/activity_browser/app/panes/__init__.py new file mode 100644 index 000000000..e6b2aab5a --- /dev/null +++ b/activity_browser/app/panes/__init__.py @@ -0,0 +1,10 @@ +from .database_products import DatabaseProductsPane +from .databases import DatabasesPane +from .impact_categories import ImpactCategoriesPane +from .calculation_setups import CalculationSetupsPane + +base_panes = { + "Databases": DatabasesPane, + "Impact Categories": ImpactCategoriesPane, + "Calculation Setups": CalculationSetupsPane, +} diff --git a/activity_browser/app/panes/calculation_setups.py b/activity_browser/app/panes/calculation_setups.py new file mode 100644 index 000000000..c5efdf014 --- /dev/null +++ b/activity_browser/app/panes/calculation_setups.py @@ -0,0 +1,202 @@ +from qtpy import QtWidgets, QtGui +from loguru import logger + +import bw2data as bd +import pandas as pd + +from activity_browser import app +from activity_browser.ui import widgets, delegates, core + + +class CalculationSetupsPane(widgets.ABAbstractPane): + title = "Calculation Setups" + unique = True + + def __init__(self, parent): + """ + Initializes the CalculationSetupsPane. + + This constructor sets up the view and model for displaying calculation setups, + configures the view's appearance and behavior, and builds the layout while + connecting necessary signals. + + Args: + parent (QtWidgets.QWidget): The parent widget for this pane. + """ + super().__init__(parent) + self.model = CalculationSetupsModel(parent=self) + self.view = CalculationSetupsView() + self.view.setModel(self.model) + + self.view.setAlternatingRowColors(True) + + self.build_layout() + self.connect_signals() + + def connect_signals(self): + """ + Connects the signals to the appropriate slots. + """ + app.signals.meta.calculation_setups_changed.connect(self.sync) + + def build_layout(self): + """ + Builds the layout of the widget. + """ + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.view) + layout.setContentsMargins(5, 0, 5, 5) + self.setLayout(layout) + + def sync(self): + """ + Synchronizes the model with the current state of the calculation setups. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + df = self.build_df() + self.model.set_dataframe(df) + self.view.resizeColumnToContents(0) + + def build_df(self) -> pd.DataFrame: + """ + Builds a DataFrame from the calculation setups. + + Returns: + pd.DataFrame: The DataFrame containing the calculation setups data. + """ + data = [] + for cs in bd.calculation_setups: + data.append( + { + "name": cs, + "functional_units": len(bd.calculation_setups[cs].get("inv", [])), + "impact_categories": len(bd.calculation_setups[cs].get("ia", [])), + } + ) + + cols = ["name", "functional_units", "impact_categories"] + + return pd.DataFrame(data, columns=cols) + + +class CalculationSetupsView(widgets.ABTreeView): + """ + A view that displays the calculation setups in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + """ + defaultColumnDelegates = { + "name": delegates.StringDelegate, + } + + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m, p: m.add(app.actions.CSNew), + lambda m, p: m.add(app.actions.CSOpen, p.calculation_setups, + enable=bool(p.calculation_setups)), + lambda m, p: m.add(app.actions.CSDelete, p.calculation_setups, + enable=bool(p.calculation_setups)), + lambda m, p: m.add(app.actions.CSRename, p.calculation_setups[0] if p.single_selection else None, + enable=p.single_selection), + lambda m: m.addSeparator(), + lambda m, p: m.add(app.actions.CSCalculate, p.calculation_setups[0] if p.single_selection else None, + enable=p.single_selection), + ] + + @property + def calculation_setups(self): + if not self.selectedIndexes(): + return [] + names = self.model().values_from_indices("name", self.selectedIndexes()) + return list(set(names)) + + @property + def single_selection(self): + return len(self.calculation_setups) == 1 + + class HeaderMenu(QtWidgets.QMenu): + """ + A header menu for the DatabasesView. Currently not used. + """ + + def __init__(self, *args, **kwargs): + super().__init__() + + def __init__(self, parent=None): + super().__init__(parent) + self.setAcceptDrops(True) + + def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent): + """ + Handles the mouse double click event to open the selected calculation setups. + + Args: + event (QtGui.QMouseEvent): The mouse double click event. + """ + index = self.indexAt(event.pos()) + + if not index.isValid(): + return + + row = self.model().row(index) + if row is None: + return + + app.actions.CSOpen.run(row["name"]) + + + def dragMoveEvent(self, event) -> None: + pass + + def dragEnterEvent(self, event): + if event.mimeData().hasFormat("application/bw-nodekeylist"): + keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") + for key in keys: + act = bd.get_node(key=key) + if act["type"] not in bd.labels.product_node_types + ["processwithreferenceproduct"]: + keys.remove(key) + + if not keys: + return + + event.accept() + + def dropEvent(self, event) -> None: + event.accept() + + keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") + for key in keys: + act = bd.get_node(key=key) + if act["type"] not in bd.labels.product_node_types + ["processwithreferenceproduct"]: + keys.remove(key) + + functional_units = [{key: 1.0} for key in keys] + + app.actions.CSNew.run(functional_units=functional_units) + + +class CalculationSetupsModel(core.ABTreeModel): + """ + A model representing the data for the calculation setups. + """ + + def fontData(self, index): + """ + Provides font data for the model. + + Args: + index: The index for which to provide font data. + + Returns: + QtGui.QFont: The font data for the index. + """ + column_name = self.column_name(index) + + if column_name == "name": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font + + return None + diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py new file mode 100644 index 000000000..e8a2fe6c1 --- /dev/null +++ b/activity_browser/app/panes/database_products.py @@ -0,0 +1,608 @@ +import threading + +from loguru import logger +from time import time +from threading import Thread + +import pandas as pd +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt, QModelIndex + +import bw2data as bd + +from activity_browser import ui, app +from activity_browser.ui import core, widgets, delegates, icons +from activity_browser.bwutils.commontasks import database_is_locked, database_is_legacy, is_node_biosphere, nodes_to_excel + + +NODETYPES = { + "all_nodes": [], + "processes": ["process", "multifunctional", "processwithreferenceproduct", "nonfunctional"], + "products": ["product", "processwithreferenceproduct", "waste"], + "biosphere": ["natural resource", "emission", "inventory indicator", "economic", "social"], +} + + +class DatabaseProductsPane(widgets.ABAbstractPane): + """ + A widget that displays products related to a specific database. + + Attributes: + database (bd.Database): The database to display products for. + model (ProductModel): The model containing the data for the products. + table_view (ProductView): The view displaying the products. + search (widgets.ABLineEdit): The search bar for quick search. + """ + def __init__(self, parent, db_name: str): + """ + Initializes the DatabaseProductsPane widget. + + Args: + parent (QtWidgets.QWidget): The parent widget. + db_name (str): The name of the database to display products for. + """ + self.name = "database_products_pane_" + db_name + + super().__init__(parent) + + self.database = bd.Database(db_name) + self.title = db_name + self.simple = True + + # initialize the model + self.model = ProductModel(parent=self, chunk_size=20, enable_sorting=True) + + # Create the QTableView and set the model + self.table_view = ProductView(self, db_name=db_name) + self.table_view.setUniformRowHeights(True) + self.table_view.setModel(self.model) + + self.search_bar = widgets.MetaDataAutoCompleteTextEdit(self) + self.search_bar.database_name = db_name + self.search_bar.setMaximumHeight(30) + self.search_bar.setPlaceholderText("Quick Search") + + # Create loading indicator with spinner + self.loading_spinner = QtWidgets.QProgressBar() + self.loading_spinner.setRange(0, 0) # Indeterminate/busy indicator + self.loading_spinner.setTextVisible(False) + self.loading_spinner.setMaximumWidth(200) + self.loading_spinner.setMaximumHeight(20) + + self.loading_label = widgets.ABLabel("Loading database...") + self.loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + font = self.loading_label.font() + font.setPointSize(14) + self.loading_label.setFont(font) + self.loading_label.setStyleSheet("color: gray; padding: 10px;") + + # Create simple/detailed view toggle + self.view_toggle = QtWidgets.QCheckBox("Details") + self.view_toggle.setChecked(not self.simple) + self.view_toggle.setToolTip("Toggle between simple and detailed view") + + self.build_layout() + self.connect_signals() + self.update_loading_state() + self.sync() + + def build_layout(self): + # Create a stacked layout to switch between loading and table view + self.stacked_layout = QtWidgets.QStackedLayout() + + # Page 0: Loading indicator with spinner + loading_widget = QtWidgets.QWidget(self) + loading_layout = QtWidgets.QVBoxLayout(loading_widget) + loading_layout.addStretch() + loading_layout.addWidget(self.loading_spinner, alignment=Qt.AlignmentFlag.AlignCenter) + loading_layout.addWidget(self.loading_label) + loading_layout.addStretch() + self.stacked_layout.addWidget(loading_widget) + + # Page 1: Table view + table_widget = QtWidgets.QWidget(self) + table_layout = QtWidgets.QVBoxLayout(table_widget) + table_layout.setSpacing(0) + table_layout.setContentsMargins(0, 0, 0, 0) + table_layout.addWidget(self.table_view) + self.stacked_layout.addWidget(table_widget) + + # Create top bar with search and toggle + top_bar = QtWidgets.QHBoxLayout() + top_bar.addWidget(self.search_bar) + top_bar.addWidget(self.view_toggle) + + layout = QtWidgets.QVBoxLayout(self) + layout.addLayout(top_bar) + layout.addLayout(self.stacked_layout) + + # Set the table view as the central widget of the window + self.setLayout(layout) + + def connect_signals(self): + app.signals.metadata.synced.connect(self.on_metadata_changed) + app.signals.database.deleted.connect(self.on_database_deleted) + + self.view_toggle.checkStateChanged.connect(self.on_mode_switch) + self.search_bar.textChangedDebounce.connect(self.sync) + + def on_metadata_changed(self, added, updated, deleted): + # Check if primary data has finished loading + self.update_loading_state() + + if any(db == self.database.name for db, code in added | updated | deleted): + self.sync() + + def update_loading_state(self): + """ + Updates the loading state based on whether primary metadata has loaded. + Shows the loading indicator if primary data is still loading, otherwise shows the table. + """ + if app.metadata.loader.secondary_status == "done": + # Show table view + self.stacked_layout.setCurrentIndex(1) + else: + # Show loading indicator + self.stacked_layout.setCurrentIndex(0) + + def sync(self): + """ + Synchronizes the widget with the current state of the database. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + t = time() + df = self.build_df() + + if self.search_bar.toPlainText().strip(): + # Reset sorting when searching + self.model.sorted_column = None + self.model.sort_order = Qt.SortOrder.AscendingOrder + + self.model.set_dataframe(df) + + self.update_table_style() + self.update_column_visibility() + + logger.debug(f"Synced DatabaseProductsPane in {time() - t:.2f} seconds") + + def update_table_style(self): + self.table_view.header().setHidden(self.simple) + self.table_view.viewport().setBackgroundRole( + QtGui.QPalette.ColorRole.Window if self.simple else QtGui.QPalette.ColorRole.Base) + self.table_view.setFrameShape( + QtWidgets.QFrame.Shape.NoFrame if self.simple else QtWidgets.QFrame.Shape.StyledPanel) + + def update_column_visibility(self): + columns = self.model.columns() + df = self.model.df + + for index, col in enumerate(columns): + if col == "index": + continue + if col == "node": + self.table_view.setColumnHidden(index, not self.simple) + continue + + if df[col].isna().all() or self.simple: + self.table_view.hideColumn(index) + else: + self.table_view.showColumn(index) + + self.table_view.reset() + + def build_df(self) -> pd.DataFrame: + """ + Builds a DataFrame from the database products. + + Returns: + pd.DataFrame: The DataFrame containing the products data. + """ + t = time() + cols = ["name", "key", "processor", "product", "type", "unit", "location", "id", "categories", "properties"] + + query = self.search_bar.toPlainText().strip() + if query: + df = app.metadata.search_database(query, self.database.name, cols) + else: + df = app.metadata.get_database_metadata(self.database.name, cols) + + processors = set(df["processor"].dropna().unique()) + df = df.drop(processors, errors="ignore") + df.rename(columns={"id": "_id"}, inplace=True) + + if not df.properties.isna().all(): + props_df = df[df.properties.notna()] + props_df = pd.DataFrame(list(props_df.get("properties")), index=props_df.key) + props_df.rename(lambda col: f"property_{col}", axis="columns", inplace=True) + + df = df.merge( + props_df, + left_on="key", + right_index=True, + how="left", + ) + + df["node"] = None + + cols = ["name", "product", "categories", "unit", "location", "key", "processor", "type", "node"] + cols += [col for col in df.columns if col.startswith("property")] + cols += ["_id"] + + logger.debug(f"Built DatabaseProductsPane dataframe in {time() - t:.2f} seconds") + + return df[cols].reset_index(drop=True) + + def on_database_deleted(self, db_name: str): + """ + Handles the database deleted signal by closing the widget if the database is deleted. + + Args: + db_name (str): The name of the deleted database. + """ + if db_name == self.database.name: + self.deleteLater() + + def on_mode_switch(self, check: Qt.CheckState): + """ + Handles the mode switch between simple and detailed view. + + Args: + check (Qt.CheckState): The check state of the toggle. + """ + self.simple = check == Qt.CheckState.Unchecked + self.update_table_style() + self.update_column_visibility() + + +class ProductView(ui.widgets.ABTreeView): + """ + A view that displays the products in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + """ + defaultColumnDelegates = { + "categories": delegates.ListDelegate, + "key": delegates.StringDelegate, + "processor": delegates.StringDelegate, + "node": delegates.CardDelegate, + } + + class ContextMenu(ui.widgets.ABMenu): + menuSetup = [ + lambda m, p: m.add(app.actions.ActivityOpen, p.selected_activities, + text="Open process" if len(p.selected_activities) == 1 else "Open processes", + enable=len(p.selected_activities) > 0 + ), + lambda m: m.addSeparator(), + lambda m, p: m.add(app.actions.ActivityNewProcess, p.db_name, + enable=not database_is_locked(p.db_name), + ), + lambda m, p: m.add(app.actions.ActivityDuplicate, p.selected_activities, + text="Duplicate process" if len(p.selected_activities) == 1 else "Duplicate processes", + enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), + ), + lambda m, p: m.add(app.actions.ActivityDuplicateToDB, p.selected_activities, + text="Duplicate process to database" if len(p.selected_activities) == 1 else "Duplicate processes to database", + enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), + ), + lambda m: m.addSeparator(), + lambda m, p: m.add(app.actions.ActivityDelete, p.selected_activities, + text="Delete process" if len(p.selected_activities) == 1 else "Delete processes", + enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), + ), + lambda m, p: m.add(app.actions.ActivityDelete, p.selected_products, + text="Delete product" if len(p.selected_products) == 1 else "Delete products", + enable=len(p.selected_products) > 0 and not + database_is_locked(p.db_name) and not + database_is_legacy(p.db_name), + ), + lambda m: m.addSeparator(), + lambda m, p: m.add(app.actions.CSNew, + functional_units=[{prod: m.get_functional_unit_amount(prod)} for prod in p.selected_products], + enable=len(p.selected_products) > 0, + text="Create setup" + ), + lambda m, p: m.add(app.actions.ActivitySDFToClipboard, p.selected_products, + enable=len(p.selected_products) > 0, + ), + ] + + @staticmethod + def get_functional_unit_amount(key): + from activity_browser.bwutils.commontasks import refresh_node + excs = list(refresh_node(key).upstream(["production"])) + exc = excs[0] if len(excs) == 1 else {} + return exc.get("amount", 1.0) + + def __init__(self, parent: DatabaseProductsPane, db_name: str): + """ + Initializes the ProductView. + + Args: + parent (DatabaseProductsPane): The parent widget. + db_name (str): The name of the database. + """ + super().__init__(parent) + self.setSortingEnabled(True) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DragDrop) + self.setSelectionBehavior(ui.widgets.ABTreeView.SelectionBehavior.SelectRows) + self.setSelectionMode(ui.widgets.ABTreeView.SelectionMode.ExtendedSelection) + + self.db_name = db_name + self.pane = parent + + self.propertyDelegate = delegates.PropertyDelegate(self) + self.overlay = None + + def setDefaultColumnDelegates(self): + """ + Sets the default column delegates for the view. + """ + super().setDefaultColumnDelegates() + + columns = self.model().columns() + for i, col_name in enumerate(columns): + if not col_name.startswith("property_"): + continue + # Set the delegate for property columns + self.setItemDelegateForColumn(i, self.propertyDelegate) + + def mouseDoubleClickEvent(self, event) -> None: + """ + Handles the mouse double click event to open the selected activities. + + Args: + event: The mouse double click event. + """ + if self.selected_activities: + app.actions.ActivityOpen.run(self.selected_activities) + + def keyPressEvent(self, event) -> None: + """ + Handles key press events. Specifically handles Ctrl+C to copy selected data. + + Args: + event: The key press event. + """ + if event.modifiers() & Qt.KeyboardModifier.ControlModifier: + if event.key() == Qt.Key.Key_C: # Copy + self.copy_selection_to_clipboard() + return + if event.key() == Qt.Key.Key_V: + self.copy_from_clipboard() + if event.key() == Qt.Key.Key_A: # Select All + self.selectAll() + return + if event.key() == Qt.Key.Key_F: # Find + self.pane.search_bar.setFocus() + return + if event.key() == Qt.Key.Key_Delete: + if database_is_locked(self.db_name): + return + if self.selected_products: + app.actions.ActivityDelete.run(self.selected_products) + return + + super().keyPressEvent(event) + + def copy_selection_to_clipboard(self): + selection = self.selectedIndexes() + mime_data = self.model().mimeData(selection) + + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setMimeData(mime_data) + + def copy_from_clipboard(self): + if database_is_locked(self.db_name): + return + + clipboard = QtWidgets.QApplication.clipboard() + mime_data = clipboard.mimeData() + + if mime_data.hasFormat("application/bw-nodekeylist"): + keys: list = mime_data.retrievePickleData("application/bw-nodekeylist") + keys = list(set(keys)) + + app.actions.ActivityDuplicateToDB.run(keys, self.db_name) + + def dragEnterEvent(self, event): + """ + Handles the drag enter event. + + Args: + event: The drag enter event. + """ + if event.source() == self: + return + + if database_is_locked(self.db_name): + return + + if event.mimeData().hasFormat("application/bw-nodekeylist"): + keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") + + if any(is_node_biosphere(key) for key in keys): + return + + self.overlay = widgets.ABDropOverlay(self, text="Drop here to duplicate to this database") + self.overlay.show() + event.accept() + + def dragMoveEvent(self, event): + pass + + def dragLeaveEvent(self, event): + """ + Handles the drag leave event. + + Args: + event: The drag leave event. + """ + if self.overlay: + self.overlay.deleteLater() + + def dropEvent(self, event): + """ + Handles the drop event. + + Args: + event: The drop event. + """ + logger.debug(f"Dropevent from: {type(event.source()).__name__} to: {self.__class__.__name__}") + # Reset the palette on drop + if self.overlay: + self.overlay.deleteLater() + + keys: list = event.mimeData().retrievePickleData("application/bw-nodekeylist") + keys = list(set(keys)) + + app.actions.ActivityDuplicateToDB.run(keys, self.db_name) + + @property + def selected_products(self) -> list[tuple]: + """ + Returns the selected products. + + Returns: + list[tuple]: The list of selected products. + """ + keys = self.model().values_from_indices("key", self.selectedIndexes()) + types = self.model().values_from_indices("type", self.selectedIndexes()) + + return list({key for key, type in zip(keys, types) if not type == "nonfunctional"}) + + @property + def selected_activities(self) -> list[tuple]: + """ + Returns the selected activities. + + Returns: + list[tuple]: The list of selected activities. + """ + processors = self.model().values_from_indices("processor", self.selectedIndexes()) + keys = self.model().values_from_indices("key", self.selectedIndexes()) + + return list({processor if not pd.isna(processor) else key for processor, key in zip(processors, keys)}) + + +class ProductModel(ui.core.ABTreeModel): + #-- flag overrides --- + def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: + return True + + # -- data overrides --- + def displayData(self, index: QModelIndex) -> any: + column_name = self.column_name(index) + if column_name != "node": + return super().displayData(index) + + row = self.row(index) + + if row is None: + return None + + # Get the product or name for title + title = row.get("product") or row.get("name") + + # Build subtitle with name (if product exists) or type + subtitle_parts = [] + if row.get("product") and row.get("name"): + # If there's both product and name, show name as subtitle + subtitle_parts.append(row.get("name")) + elif row.get("type"): + # Otherwise show type + subtitle_parts.append(row.get("type").capitalize()) + + subtitle = " | ".join(subtitle_parts) if subtitle_parts else None + + # Build categories list from unit, location, database + categories = [] + if row.get("unit"): + categories.append(str(row.get("unit"))) + if row.get("location"): + categories.append(str(row.get("location"))) + if row.get("key") and isinstance(row.get("key"), tuple): + categories.append(str(row.get("key")[0])) # database name + + # Add actual categories if they exist + node_categories = row.get("categories") + if node_categories and isinstance(node_categories, (list, tuple)): + categories.extend([str(cat) for cat in node_categories if str(cat).strip()]) + + return { + "title": title, + "subtitle": subtitle, + "categories": categories if categories else None, + } + + def decorationData(self, index: QtCore.QModelIndex) -> any: + column_name = self.column_name(index) + node_type = self.get(index, "type") + + if column_name not in ["name", "product", "node"]: + return None + + if column_name == "name" and node_type in ["product", "waste"]: + return icons.qicons.process + if column_name in ["name", "node"] and node_type == "processwithreferenceproduct": + return icons.qicons.processproduct + if column_name in ["name", "node"] and node_type in NODETYPES["biosphere"]: + return icons.qicons.biosphere + if column_name == "name": + return icons.qicons.empty + + if column_name in ["product", "node"] and node_type in ["product", "processwithreferenceproduct"]: + return icons.qicons.product + if column_name in ["product", "node"] and node_type == "waste": + return icons.qicons.waste + return icons.qicons.empty + + def toolTipData(self, index: QtCore.QModelIndex) -> str: + column_name = self.column_name(index) + if column_name not in ["name", "product"]: + return None + + row = self.row(index) + + html_tooltip = f""" + {row.get('product')}
+ {row.get('name')}
+
+ {row.get('unit')} | {row.get('location')} | {row.get('type')} + """ + + return html_tooltip + + def mimeData(self, indices: list[QtCore.QModelIndex]): + """ + Returns the mime data for the given indices. + + Args: + indices (list[QtCore.QModelIndex]): The indices to get the mime data for. + + Returns: + core.ABMimeData: The mime data. + """ + data = core.ABMimeData() + keys = set(self.values_from_indices("key", indices)) + keys.update(self.values_from_indices("processor", indices)) + keys = {key for key in keys if isinstance(key, tuple)} + data.setPickleData("application/bw-nodekeylist", list(keys)) + + # Add HTML data for Excel with bold formatting + thread = threading.Thread(target=self.set_excel_nodes_threaded, args=(data, keys)) + thread.start() + + return data + + @staticmethod + def set_excel_nodes_threaded(data, keys): + excel_string = nodes_to_excel(list(keys)) + try: + data.setHtml(excel_string) + except RuntimeError: + pass diff --git a/activity_browser/app/panes/databases.py b/activity_browser/app/panes/databases.py new file mode 100644 index 000000000..92b4d1ece --- /dev/null +++ b/activity_browser/app/panes/databases.py @@ -0,0 +1,327 @@ +import datetime +from loguru import logger + +from qtpy import QtWidgets, QtGui, QtCore +from qtpy.QtCore import Qt + +import bw2data as bd +import pandas as pd + +from activity_browser import app +from activity_browser.bwutils.commontasks import count_database_records +from activity_browser.ui import widgets, icons, delegates, core +from activity_browser.app.menu_bar import ImportDatabaseMenu + + +class DatabasesPane(widgets.ABAbstractPane): + """ + A widget that displays the databases and their details. + + Attributes: + view (DatabasesView): The view displaying the databases. + model (DatabasesModel): The model containing the data for the databases. + """ + title = "Databases" + unique = True + + def __init__(self, parent): + """ + Initializes the DatabasesPane widget. + + Args: + parent (QtWidgets.QWidget): The parent widget. + """ + super().__init__(parent) + self._populate_later_flag = False + + self.model = DatabasesModel(parent=self) + self.view = DatabasesView() + self.view.setModel(self.model) + + self.view.setAlternatingRowColors(True) + self.view.setIndentation(0) + + self.build_layout() + self.connect_signals() + + def connect_signals(self): + """ + Connects the signals to the appropriate slots. + """ + app.signals.meta.databases_changed.connect(self.syncLater) + app.signals.metadata.synced.connect(self.syncLater) + app.signals.database.deleted.connect(self.syncLater) + app.signals.database_read_only_changed.connect(self.syncLater) + + def build_layout(self): + """ + Builds the layout of the widget. + """ + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.view) + layout.setContentsMargins(5, 0, 5, 5) + self.setLayout(layout) + + def syncLater(self): + """ + Schedules a sync operation to be performed later. + """ + + def slot(): + self._populate_later_flag = False + self.sync() + self.thread().eventDispatcher().awake.disconnect(slot) + + if self._populate_later_flag: + return + + self._populate_later_flag = True + self.thread().eventDispatcher().awake.connect(slot) + + def sync(self): + """ + Synchronizes the model with the current state of the databases. + """ + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + df = self.build_df() + self.model.set_dataframe(df) + self.view.resizeColumnToContents(1) + self.view.header().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Fixed) + + def build_df(self) -> pd.DataFrame: + """ + Builds a DataFrame from the databases. + + Returns: + pd.DataFrame: The DataFrame containing the databases data. + """ + data = [] + for name in bd.databases: + # get the modified time, in case it doesn't exist, just write 'now' in the correct format + dt = bd.databases[name].get("modified", datetime.datetime.now().isoformat()) + dt = datetime.datetime.strptime(dt, "%Y-%m-%dT%H:%M:%S.%f") + + # final column includes interactive checkbox which shows read-only state of db + data.append( + { + "name": name, + "depends": ", ".join(bd.databases[name].get("depends", [])), + "modified": dt, + "records": count_database_records(name), + "read_only": bd.databases[name].get("read_only", True), + "default_allocation": bd.databases[name].get("default_allocation", "unspecified"), + "backend": bd.databases[name].get("backend") + } + ) + + cols = ["read_only", "name", "records", "depends", "default_allocation", "modified", "backend"] + + return pd.DataFrame(data, columns=cols) + + +class DatabasesView(widgets.ABTreeView): + """ + A view that displays the databases in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + """ + defaultColumnDelegates = { + "modified": delegates.DateTimeDelegate, + } + + class ExportDatabaseContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m: m.setTitle("Export database" if len(m.parent().selected_databases) == 1 else "Export databases"), + lambda m, p: m.add(app.actions.DatabaseExportExcel, p.selected_databases if p.selected_databases else [], + enable=len(p.selected_databases) >= 1, + text="to .xlsx", + ), + lambda m, p: m.add(app.actions.DatabaseExportBW2Package, p.selected_databases if p.selected_databases else [], + enable=len(p.selected_databases) >= 1, + text="to .bw2package", + ), + ] + + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m, p: m.add(app.actions.DatabaseNew), + lambda m: m.addMenu(ImportDatabaseMenu(m)), + lambda m, p: m.addMenu(DatabasesView.ExportDatabaseContextMenu(parent=p)), + lambda m: m.addSeparator(), + lambda m, p: m.add(app.actions.DatabaseDelete, p.selected_databases if p.selected_databases else [], + enable=len(p.selected_databases) >= 1, + text="Delete databases" if len(p.selected_databases) > 1 else "Delete database", + ), + lambda m, p: m.add(app.actions.DatabaseDuplicate, p.selected_databases[0] if p.selected_databases else None, + enable=len(p.selected_databases) == 1), + lambda m, p: m.add(app.actions.DatabaseRelink, p.selected_databases[0] if p.selected_databases else None), + lambda m, p: m.add(app.actions.DatabaseProcess, p.selected_databases[0] if p.selected_databases else None, + enable=len(p.selected_databases) == 1), + lambda m: m.addSeparator(), + lambda m, p: m.add(app.actions.DatabaseSetReadonly, p.selected_databases[0] if p.selected_databases else None, + not m.selected_readonly, + enable=len(p.selected_databases) == 1, + text="Unlock database" if m.selected_readonly else "Lock database", + ), + ] + + @property + def selected_readonly(self): + """ + Returns the read-only state of the selected database. + + Returns: + bool: The read-only state of the selected database. + """ + if not self.parent().selected_databases: + return None + index = self.parent().selectedIndexes()[0] + row = self.parent().model().row(index) + return row.get("read_only") if row is not None else None + + class HeaderMenu(QtWidgets.QMenu): + """ + A header menu for the DatabasesView. Currently not used. + """ + + def __init__(self, *args, **kwargs): + super().__init__() + + def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent): + """ + Handles the mouse double click event to toggle the read-only state or select the database. + + Args: + event (QtGui.QMouseEvent): The mouse double click event. + """ + index = self.indexAt(event.pos()) + + if not index.isValid(): + return super().mouseDoubleClickEvent(event) + + row = self.model().row(index) + if row is None: + return super().mouseDoubleClickEvent(event) + + db_name = row.get("name") + + if index.column() == 1: + read_only = row.get("read_only") + app.actions.DatabaseSetReadonly.run(db_name, not read_only) + return + + app.actions.DatabaseOpen.run([db_name]) + + def keyPressEvent(self, event: QtGui.QKeyEvent): + """ + Handles key press events. Specifically handles the Delete key to delete selected databases. + + Args: + event (QtGui.QKeyEvent): The key press event. + """ + if event.key() == Qt.Key_Delete: + if self.selected_databases: + app.actions.DatabaseDelete.run(self.selected_databases) + return + + super().keyPressEvent(event) + + @property + def selected_databases(self) -> list: + """ + Returns the database name of the user-selected index. + + Returns: + str: The name of the selected database. + """ + if not self.selectedIndexes(): + return [] + names = self.model().values_from_indices("name", self.selectedIndexes()) + return list(set(names)) + + +class DatabasesModel(core.ABTreeModel): + """ + A model representing the data for the databases. + """ + + def decorationData(self, index: QtCore.QModelIndex) -> any: + """ + Provides decoration data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide decoration data. + + Returns: + The decoration data for the index. + """ + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return None + + if column_name == "read_only": + return icons.qicons.locked if row.get("read_only") else icons.qicons.empty + + return None + + def displayData(self, index: QtCore.QModelIndex) -> any: + """ + Provides display data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide display data. + + Returns: + The display data for the index. + """ + column_name = self.column_name(index) + row = self.row(index) + + if row is None: + return None + + if column_name == "read_only": + return None + + return row.get(column_name) + + def fontData(self, index: QtCore.QModelIndex) -> any: + """ + Provides font data for the model. + + Args: + index (QtCore.QModelIndex): The index for which to provide font data. + + Returns: + QtGui.QFont: The font data for the index. + """ + column_name = self.column_name(index) + + if column_name == "name": + font = QtGui.QFont() + font.setWeight(QtGui.QFont.Weight.DemiBold) + return font + + return None + + def headerData(self, section, orientation=Qt.Orientation.Horizontal, role=Qt.ItemDataRole.DisplayRole): + """ + Provides header data for the model. + + Args: + section (int): The section index. + orientation (Qt.Orientation): The orientation of the header. + role (Qt.ItemDataRole): The role for which to provide header data. + + Returns: + The header data for the model. + """ + if section == 1 and role == Qt.ItemDataRole.DisplayRole: + return "" + if section == 1 and role == Qt.ItemDataRole.DecorationRole: + return icons.qicons.unlocked + return super().headerData(section, orientation, role) diff --git a/activity_browser/app/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py new file mode 100644 index 000000000..f909a8581 --- /dev/null +++ b/activity_browser/app/panes/impact_categories.py @@ -0,0 +1,178 @@ +from qtpy import QtWidgets, QtCore +from loguru import logger + +import bw2data as bd +import pandas as pd + +from activity_browser import app, app +from activity_browser.ui import widgets, core, delegates + + +class ImpactCategoriesPane(widgets.ABAbstractPane): + title = "Impact Categories" + unique = True + + def __init__(self, parent=None): + super().__init__(parent) + self.model = ImpactCategoriesModel(parent=self) + self.view = ImpactCategoriesView() + self.view.setModel(self.model) + + self.view.setSelectionMode(QtWidgets.QTableView.SingleSelection) + self.view.setDragEnabled(True) + self.view.setDragDropMode(QtWidgets.QTableView.DragDropMode.DragOnly) + self.view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + + self.search = widgets.ABLineEdit(self) + self.search.setMaximumHeight(30) + self.search.setPlaceholderText("Quick Search") + + self.search.textChangedDebounce.connect(self.view.setAllFilter) + + self.build_layout() + self.connect_signals() + + def build_layout(self): + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.search) + layout.addWidget(self.view) + layout.setContentsMargins(5, 0, 5, 5) + + self.setLayout(layout) + + def connect_signals(self): + app.signals.meta.methods_changed.connect(self.sync) + app.signals.database_read_only_changed.connect(self.sync) + + def sync(self): + logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}") + + df = self.build_df() + 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: + return pd.DataFrame(columns=cols) + + return df[cols] + + +class ImpactCategoriesView(widgets.ABTreeView): + defaultColumnDelegates = { + "groups": delegates.ListDelegate, + } + + class ContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m, p: m.add(app.actions.MethodNew), + lambda m: m.addSeparator(), + lambda m, p: m.add(app.actions.MethodOpen, p.selected_impact_categories, + text="Open impact category" if len(p.selected_impact_categories) == 1 else "Open impact categories", + enable=len(p.selected_impact_categories) > 0 + ), + lambda m, p: m.add(app.actions.MethodDelete, p.selected_impact_categories, + text="Delete impact category" if len( + p.selected_impact_categories) == 1 else "Delete impact categories", + enable=len(p.selected_impact_categories) > 0 + ), + lambda m, p: m.add(app.actions.MethodDuplicate, p.selected_impact_categories, + text="Duplicate impact category", + enable=len(p.selected_impact_categories) == 1 + ), + lambda m, p: m.add(app.actions.MethodRename, p.selected_impact_categories, + text="Rename impact category", + enable=len(p.selected_impact_categories) == 1 + ), + ] + + @property + def selected_impact_categories(self): + if not self.selectedIndexes(): + return [] + + indices = [i for i in self.selectedIndexes() if i.column() == 0] + impact_categories = [] + + for index in indices: + impact_categories.extend(self.model().get_impact_categories(index)) + + return list(set(impact_categories)) + + def mouseDoubleClickEvent(self, event) -> None: + if self.selected_impact_categories: + app.actions.MethodOpen.run(self.selected_impact_categories) + + +class ImpactCategoriesModel(core.ABTreeModel): + """ + A model representing the data for the impact categories. + """ + + def indexDragEnabled(self, index: QtCore.QModelIndex) -> bool: + """Enable drag for all items.""" + return True + + def mimeData(self, indices: list[QtCore.QModelIndex]): + """ + Returns the mime data for the given indices. + + Args: + indices (list[QtCore.QModelIndex]): The indices to get the mime data for. + + Returns: + core.ABMimeData: The mime data. + """ + data = core.ABMimeData() + names = [] + + for index in indices: + names += self.get_impact_categories(index) + + data.setPickleData("application/bw-methodnamelist", list(set(names))) + return data + + def get_impact_categories(self, index: QtCore.QModelIndex): + """ + Get all impact category method names for the given index. + + For leaf nodes (full depth paths), returns the single method name. + For branch nodes (partial depth paths), returns all child method names. + + Args: + index: The index to get impact categories for. + + Returns: + list: List of method name tuples. + """ + if not index.isValid(): + return [] + + node = index.internalPointer() + + if not isinstance(node, core.TreeNode): + return [] + + # If this is a leaf node, return its method name + if node.is_leaf: + row = self.row(index) + if row is not None: + return [row["_method_name"]] + return [] + + # If this is a branch node, collect all child method names recursively + ics = [] + for i, child_node in enumerate(node.children): + if i >= node.loaded_count: + break # Only process loaded children + child_index = self.createIndex(i, 0, child_node) + ics += self.get_impact_categories(child_index) + + return ics + diff --git a/activity_browser/app/signalling.py b/activity_browser/app/signalling.py new file mode 100644 index 000000000..16fd0d744 --- /dev/null +++ b/activity_browser/app/signalling.py @@ -0,0 +1,328 @@ +from loguru import logger +from time import time + +from qtpy.QtCore import QObject, Signal, SignalInstance, QTimer, QEvent +from blinker import signal as blinker_signal + + + + +class NodeSignals(QObject): + changed: SignalInstance = Signal(object, object) + deleted: SignalInstance = Signal(object) + database_change: SignalInstance = Signal(object, object) + code_change: SignalInstance = Signal(object, object) + + +class EdgeSignals(QObject): + changed: SignalInstance = Signal(object, object) + deleted: SignalInstance = Signal(object) + recalculated: SignalInstance = Signal() + + +class MethodSignals(QObject): + changed: SignalInstance = Signal(object) + deleted: SignalInstance = Signal(object) + renamed: SignalInstance = Signal(tuple, tuple) + + +class ParameterSignals(QObject): + changed: SignalInstance = Signal(object, object) + deleted: SignalInstance = Signal(object) + recalculated: SignalInstance = Signal() + + +class DatabaseSignals(QObject): + written: SignalInstance = Signal(object) + reset: SignalInstance = Signal(object) + deleted: SignalInstance = Signal(str) + + +class ProjectSignals(QObject): + changed: SignalInstance = Signal(object, object) # Project changed | new project dataset, old project dataset + created: SignalInstance = Signal() + deleted: SignalInstance = Signal(str) + + +class MetaSignals(QObject): + databases_changed: SignalInstance = Signal(object, object) + methods_changed: SignalInstance = Signal(object, object) + calculation_setups_changed: SignalInstance = Signal(object, object) + + +class MetaDataSignals(QObject): + """Signals for MetaDataStore updates.""" + synced: SignalInstance = Signal(set, set, set) # added, updated, deleted + + def __init__(self, parent=None): + from activity_browser.bwutils.metadata import MetaDataStore + super().__init__(parent) + + self._metadata = MetaDataStore() + self._flusher = QTimer(self, interval=100) + self._flusher.timeout.connect(self._flush_metadata) + self._flusher.start() + + def _flush_metadata(self): + added, updated, deleted = self._metadata.flush_mutations() + + if not (added or updated or deleted): + return + + t = time() + self.synced.emit(added, updated, deleted) + + logger.log("SIGNAL", f"Metadata: synced: {time() - t:.2f} seconds") + +class SettingSignals(QObject): + changed = Signal() # Settings have changed + + def __init__(self, parent=None): + from activity_browser.bwutils.settings import Settings + + super().__init__(parent) + Settings().changed.connect(self.emit_changed) + + def emit_changed(self, *args, **kwargs): + """Emit the changed signal.""" + t = time() + self.changed.emit() + logger.log("SIGNAL", f"Settings: changed: {time() - t:.2f} seconds") + + +class ABSignals(QObject): + """Signals used for the Activity Browser should be defined here. + While arguments can be passed to signals, it is good practice not to do this if possible. + Every signal should have a comment (no matter how descriptive the name of the signal) that describes what a + signal is used for and after a pipe (|), what variables are sent, if any. + """ + node = NodeSignals() + edge = EdgeSignals() + method = MethodSignals() + database = DatabaseSignals() + project = ProjectSignals() + meta = MetaSignals() + metadata = MetaDataSignals() + parameter = ParameterSignals() + settings = SettingSignals() + + # import_project = Signal() # Import a project + # export_project = Signal() # Export the current project + database_selected = Signal(str) # This database was selected (opened) | name of database + database_read_only_changed = Signal(str, bool) # The read_only state of database changed | name of database, read-only state + # database_tab_open = Signal(str) # This database tab is being viewed by user | name of database + # add_activity_to_history = Signal(tuple) + # safe_open_activity_tab = Signal(tuple) # Open activity details tab in read-only mode | key of activity + # unsafe_open_activity_tab = Signal(tuple) # Open activity details tab in editable mode | key of activity + # close_activity_tab = Signal(tuple) # Close this activity details tab | key of activity + # open_activity_graph_tab = Signal(tuple) # Open the graph-view tab | key of activity + # edit_activity = Signal(str) # An activity in this database may now be edited | name of database + # added_parameter = Signal(str, str, str) # This parameter has been added | name of the parameter, amount, type (project, database or activity) + # parameters_changed = Signal() # The parameters have changed + # parameter_scenario_sync = Signal(int, object, bool) # Synchronize this data for table | index of the table, dataframe with scenario data, include default scenario + # parameter_superstructure_built = Signal(int, object) # Superstructure built from scenarios | index of the table, dataframe with scenario data + # set_default_calculation_setup = Signal() # Show the default (first) calculation setup + # calculation_setup_changed = Signal() # Calculation setup was changed + # calculation_setup_selected = Signal(str) # This calculation setup was selected (opened) | name of calculation setup + # lca_calculation = Signal(dict) # Generate a calculation setup | dictionary with name, type (simple/scenario) and potentially scenario data + # delete_method = Signal(tuple, str) # Delete this method | tuple of impact category, level of tree OR the proxy + # method_selected = Signal(tuple) # This method was selected (opened) | tuple of method + monte_carlo_finished = Signal() # The monte carlo calculations are finished + # new_statusbar_message = Signal(str) # Update the statusbar this message | message + # restore_cursor = Signal() # Restore the cursor to normal + # project_updates_available = Signal(str, int) # Project name and number of updates available + # toggle_show_or_hide_tab = Signal(str) # Show/Hide the tab with this name | name of tab + # show_tab = Signal(str) # Show this tab | name of tab + # hide_tab = Signal(str) # Hide this tab | name of tab + # hide_when_empty = Signal() # Show/Hide tab when it has/does not have sub-tabs + plugin_selected = Signal(str, bool) # This plugin was/was not selected | name of plugin, selected state + + def __getattribute__(self, item): + """Delayed loading of connecting to the brighway signals""" + setattr(ABSignals, "__getattribute__", super().__getattribute__) + import bw2data as bd + + self._project_dataset = bd.projects.dataset + + self._connect_bw_signals() + return super().__getattribute__(item) + + def _connect_bw_signals(self): + from bw2data import signals, Method + from bw2data.meta import databases, methods, calculation_setups + + patch_methods_datastore() + patch_projects() + + signals.signaleddataset_on_save.connect(self._on_signaleddataset_on_save) + signals.signaleddataset_on_delete.connect(self._on_signaleddataset_on_delete) + signals.on_activity_database_change.connect(self._on_activity_database_change) + signals.on_activity_code_change.connect(self._on_activity_code_change) + + signals.on_database_delete.connect(self._on_database_delete) + signals.on_database_reset.connect(self._on_database_reset) + signals.on_database_write.connect(self._on_database_write) + + signals.project_changed.connect(self._on_project_changed) + signals.project_created.connect(self._on_project_created) + + signals.on_activity_parameter_recalculate.connect(self._on_parameter_recalculate) + signals.on_database_parameter_recalculate.connect(self._on_parameter_recalculate) + signals.on_project_parameter_recalculate.connect(self._on_parameter_recalculate) + signals.on_activity_parameter_recalculate_exchanges.connect(self._on_parameterized_exchange_recalculate) + + databases._save_signal.connect(self._on_database_metadata_change) + setattr(methods, "_save_signal", blinker_signal("ab.patched_methods")) + methods._save_signal.connect(self._on_methods_metadata_change) + setattr(calculation_setups, "_save_signal", blinker_signal("ab.patched_calculation_setups")) + calculation_setups._save_signal.connect(self._on_cs_metadata_change) + + Method._write_signal.connect(self._on_method_write) + Method._deregister_signal.connect(self._on_method_deregister) + + def _on_signaleddataset_on_save(self, sender, old, new): + from bw2data.backends import ActivityDataset, ExchangeDataset + from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter + + if isinstance(new, ActivityDataset): + t = time() + self.node.changed.emit(new, old) + logger.log("SIGNAL", f"Node: changed: {time() - t:.2f} seconds") + elif isinstance(new, ExchangeDataset): + t = time() + self.edge.changed.emit(new, old) + logger.log("SIGNAL", f"Edge: changed: {time() - t:.2f} seconds") + elif isinstance(new, (ProjectParameter, DatabaseParameter, ActivityParameter)): + t = time() + self.parameter.changed.emit(new, old) + logger.log("SIGNAL", f"Parameter: changed: {time() - t:.2f} seconds") + else: + logger.debug(f"Unknown dataset type changed: {type(new)}") + + def _on_signaleddataset_on_delete(self, sender, old): + from bw2data.backends import ActivityDataset, ExchangeDataset + from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter + + if isinstance(old, ActivityDataset): + t = time() + self.node.deleted.emit(old) + logger.log("SIGNAL", f"Node: deleted: {time() - t:.2f} seconds") + elif isinstance(old, ExchangeDataset): + t = time() + self.edge.deleted.emit(old) + logger.log("SIGNAL", f"Edge: deleted: {time() - t:.2f} seconds") + elif isinstance(old, (ProjectParameter, DatabaseParameter, ActivityParameter)): + t = time() + self.parameter.deleted.emit(old) + logger.log("SIGNAL", f"Parameter: deleted: {time() - t:.2f} seconds") + else: + logger.debug(f"Unknown dataset type deleted: {type(old)}") + + def _on_activity_database_change(self, sender, old, new): + t = time() + self.node.database_change.emit(old, new) + logger.log("SIGNAL", f"Node: database_change: {time() - t:.2f} seconds") + + def _on_activity_code_change(self, sender, old, new): + t = time() + self.node.code_change.emit(old, new) + logger.log("SIGNAL", f"Node: code_change: {time() - t:.2f} seconds") + + def _on_database_delete(self, sender, name): + t = time() + self.database.deleted.emit(name) + logger.log("SIGNAL", f"Database: deleted: {time() - t:.2f} seconds") + + def _on_database_reset(self, sender, name): + from bw2data import Database + t = time() + self.database.reset.emit(Database(name)) + logger.log("SIGNAL", f"Database: reset: {time() - t:.2f} seconds") + + def _on_database_write(self, sender, name): + from bw2data import Database + t = time() + self.database.written.emit(Database(name)) + logger.log("SIGNAL", f"Database: written: {time() - t:.2f} seconds") + + def _on_project_changed(self, ds): + t = time() + self.project.changed.emit(ds, self._project_dataset) + self._project_dataset = ds + logger.log("SIGNAL", f"Project: changed: {time() - t:.2f} seconds") + + def _on_project_created(self, ds): + t = time() + self.project.created.emit() + logger.log("SIGNAL", f"Project: created: {time() - t:.2f} seconds") + + def _on_database_metadata_change(self, sender, old, new): + t = time() + self.meta.databases_changed.emit(old, new) + logger.log("SIGNAL", f"Meta: databased_changed: {time() - t:.2f} seconds") + + def _on_methods_metadata_change(self, sender, old, new): + t = time() + self.meta.methods_changed.emit(old, new) + logger.log("SIGNAL", f"Meta: methods_changed: {time() - t:.2f} seconds") + + def _on_cs_metadata_change(self, sender, old, new): + t = time() + self.meta.calculation_setups_changed.emit(old, new) + logger.log("SIGNAL", f"Meta: calculation_setups_changed: {time() - t:.2f} seconds") + + def _on_method_write(self, sender): + t = time() + self.method.changed.emit(sender) + logger.log("SIGNAL", f"Method: changed: {time() - t:.2f} seconds") + + def _on_method_deregister(self, sender): + t = time() + self.method.deleted.emit(sender) + logger.log("SIGNAL", f"Method: deleted: {time() - t:.2f} seconds") + + def _on_parameter_recalculate(self, sender, *args, **kwargs): + t = time() + self.parameter.recalculated.emit() + logger.log("SIGNAL", f"Parameter: recalculated: {time() - t:.2f} seconds") + + def _on_parameterized_exchange_recalculate(self, sender, *args, **kwargs): + t = time() + self.edge.recalculated.emit() + logger.log("SIGNAL", f"Edge: recalculated: {time() - t:.2f} seconds") + + +def patch_methods_datastore(): + from bw2data import Method + + def write(self, data, process=True): + original_write(self, data, process) + self._write_signal.send(self) + + def deregister(self): + original_deregister(self) + self._deregister_signal.send(self) + + original_write = Method.write + original_deregister = Method.deregister + + setattr(Method, "write", write) + setattr(Method, "deregister", deregister) + + setattr(Method, "_write_signal", blinker_signal("ab.patched_method_write")) + setattr(Method, "_deregister_signal", blinker_signal("ab.patched_method_deregister")) + + +def patch_projects(): + from bw2data.project import ProjectManager + + def delete_project(self, name=None, delete_dir=False): + from activity_browser.app import signals + original_delete(self, name, delete_dir) + t = time() + signals.project.deleted.emit(name) + logger.log("SIGNAL", f"Project: deleted: {time() - t:.2f} seconds") + + original_delete = ProjectManager.delete_project + + setattr(ProjectManager, "delete_project", delete_project) diff --git a/activity_browser/bwutils/README.md b/activity_browser/bwutils/README.md new file mode 100644 index 000000000..acbd57bf8 --- /dev/null +++ b/activity_browser/bwutils/README.md @@ -0,0 +1,56 @@ +# bwutils + +Utility functions and helpers that extend and build upon Brightway2 functionality. + +## Overview + +This module provides a collection of generic methods and utilities that wrap and extend Brightway2 operations. These utilities are used throughout the Activity Browser to avoid code duplication and provide consistent interfaces to Brightway2 functionality. + +## Directory Structure + +- **`ecoinvent_biosphere_versions/`** - Ecoinvent biosphere database version mappings +- **`io/`** - Import/export operations for data interchange +- **`metadata/`** - Metadata loading and caching for quick access +- **`searchengine/`** - Fuzzy search functionality for dataframes +- **`superstructure/`** - Superstructure scenario analysis tools + +## Key Files + +- **`commontasks.py`** - Common Brightway2 operations (database management, activity operations) +- **`errors.py`** - Custom exception classes for Brightway2 operations +- **`exporters.py`** - Export functionality for databases and activities +- **`importers.py`** - Import functionality for various LCA data formats +- **`filesystem.py`** - File system operations for Brightway2 data directories +- **`manager.py`** - High-level management of Brightway2 projects and databases +- **`montecarlo.py`** - Monte Carlo simulation helpers +- **`multilca.py`** - Multi-functional LCA calculation utilities +- **`pedigree.py`** - Pedigree matrix uncertainty handling +- **`sensitivity_analysis.py`** - Global sensitivity analysis tools +- **`settings.py`** - Settings specific to bwutils operations +- **`strategies.py`** - Import strategies and data transformation functions +- **`uncertainty.py`** - Uncertainty analysis utilities +- **`utils.py`** - General utility functions + +## Purpose + +The bwutils module serves as an abstraction layer between the Activity Browser UI and Brightway2, providing: + +1. **Consistency** - Standardized interfaces for common operations +2. **Error Handling** - Graceful handling of Brightway2 exceptions +3. **Extensions** - Additional functionality not provided by Brightway2 +4. **Integration** - Bridging between Qt UI and Brightway2 data structures + +## Usage Pattern + +Import utilities as needed throughout the application: + +```python +from activity_browser.bwutils import commontasks +``` + +## Design Principle + +Keep utilities generic and reusable. These functions should: +- Work with Brightway2 data structures +- Be independent of UI components +- Be testable without requiring a GUI diff --git a/activity_browser/bwutils/__init__.py b/activity_browser/bwutils/__init__.py index f15c26be6..2cdbf9dd5 100644 --- a/activity_browser/bwutils/__init__.py +++ b/activity_browser/bwutils/__init__.py @@ -3,19 +3,4 @@ bwutils is a collection of methods that build upon brightway2 and are generic enough to provide here so that we avoid re-typing the same code in different parts of the Activity Browser. """ -import bw_functional -from .commontasks import cleanup_deleted_bw_projects as cleanup -from .commontasks import (refresh_node, refresh_node_or_none, refresh_parameter, refresh_edge, refresh_edge_or_none, - parameters_in_scope, exchanges_to_sdf, database_is_locked, database_is_legacy, projects_by_last_opened, - node_group, is_node_product, is_node_biosphere, is_node_process) -from .metadata import AB_metadata -from .montecarlo import MonteCarloLCA -from .multilca import MLCA, Contributions -from .pedigree import PedigreeMatrix -from .sensitivity_analysis import GlobalSensitivityAnalysis -from .superstructure import SuperstructureContributions, SuperstructureMLCA -from .uncertainty import (CFUncertaintyInterface, ExchangeUncertaintyInterface, - ParameterUncertaintyInterface, - get_uncertainty_interface) -from .utils import Parameter diff --git a/activity_browser/bwutils/commontasks.py b/activity_browser/bwutils/commontasks.py index 8b616f899..9853c1998 100644 --- a/activity_browser/bwutils/commontasks.py +++ b/activity_browser/bwutils/commontasks.py @@ -1,12 +1,13 @@ +import os import hashlib import textwrap from datetime import datetime -from logging import getLogger +from loguru import logger from collections import OrderedDict import arrow import pandas as pd -import peewee as pw +import numpy as np import bw2data as bd from bw2data.parameters import ParameterBase, ProjectParameter, DatabaseParameter, ActivityParameter, Group @@ -14,11 +15,8 @@ from functools import lru_cache -from .metadata import AB_metadata from .utils import Parameter -log = getLogger(__name__) - """ bwutils is a collection of methods that build upon brightway2 and are generic enough to provide here so that we avoid re-typing the same code in different parts of the Activity Browser. @@ -105,7 +103,7 @@ def cleanup_deleted_bw_projects() -> None: NOTE: This cannot be done from within the AB. """ n_dir = bd.projects.purge_deleted_directories() - log.info(f"Deleted {n_dir} unused project directories!") + logger.info(f"Deleted {n_dir} unused project directories!") def projects_by_last_opened(): @@ -165,8 +163,9 @@ def count_database_records(name: str) -> int: """To account for possible brightway database types that do not implement the __len__ method. """ + from activity_browser.app import metadata try: - return len(AB_metadata.dataframe.loc[name]) + return len(metadata.dataframe.loc[name]) except KeyError: return 0 @@ -195,11 +194,14 @@ def get_activity_name(key, str_length=22): return ",".join(key.get("name", "").split(",")[:3])[:str_length] +def is_node_product_or_waste(node: tuple | int | bd.Node) -> bool: + return is_node_product(node) or is_node_waste(node) + def is_node_product(node: tuple | int | bd.Node) -> bool: node = refresh_node(node) raw_type = node._document.type - if raw_type in ["product", "waste", "processwithreferenceproduct"]: + if raw_type in ["product", "processwithreferenceproduct"]: return True if raw_type == "process" and len(node.upstream(kinds=["production"])): @@ -207,6 +209,15 @@ def is_node_product(node: tuple | int | bd.Node) -> bool: return False +def is_node_waste(node: tuple | int | bd.Node) -> bool: + node = refresh_node(node) + raw_type = node._document.type + + if raw_type == "waste": + return True + + return False + def is_node_biosphere(node: tuple | int | bd.Node) -> bool: node = refresh_node(node) @@ -225,12 +236,12 @@ def is_node_process(node: tuple | int | bd.Node) -> bool: return False -def refresh_node(node: tuple | int | bd.Node) -> bd.Node: +def refresh_node(node: tuple | int | np.int64 | bd.Node) -> bd.Node: if isinstance(node, bd.Node): node = bd.get_node(id=node.id) elif isinstance(node, tuple): node = bd.get_node(key=node) - elif isinstance(node, int): + elif isinstance(node, (int, np.int64)): node = bd.get_node(id=node) else: raise ValueError("Activity must be either a tuple, int or Node instance") @@ -381,16 +392,18 @@ def identify_activity_type(activity): def generate_copy_code(key: tuple) -> str: """Generate a new code to use when copying an activity""" + from activity_browser.app import metadata + db, code = key - metadata = AB_metadata.get_database_metadata(db) + meta = metadata.get_database_metadata(db) if "_copy" in code: code = code.split("_copy")[0] copies = ( - metadata["key"] + meta["key"] .apply(lambda x: x[1] if code in x[1] and "_copy" in x[1] else None) .dropna() .to_list() - if not metadata.empty + if not meta.empty else [] ) if not copies: @@ -452,7 +465,7 @@ def get_exchanges_in_scenario_difference_file_notation(exchanges): except: # The input activity does not exist. remove the exchange. - log.error( + logger.error( "Something did not work with the following exchange: {}. It was removed from the list.".format( exc ) @@ -494,3 +507,59 @@ def get_LCIA_method_name_dict(keys: list) -> dict: values: brightway2 method tuples """ return {", ".join(key): key for key in keys} + + +# Common tasks +def savefilepath( + default_file_name: str = "AB_file", file_filter: str = "All Files (*.*)" +): + """A central function to get a safe file path.""" + from qtpy import QtWidgets + + safe_name = bd.utils.safe_filename(default_file_name, add_hash=False) + filepath, _ = QtWidgets.QFileDialog.getSaveFileName( + parent=None, + caption="Choose location for saving", + dir=os.path.join(os.path.expanduser("~"), safe_name), + filter=file_filter, + ) + return filepath + + +def get_templates() -> dict: + import platformdirs, os + + base_dir = platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="ActivityBrowser") + template_dir = os.path.join(base_dir, "templates") + os.makedirs(template_dir, exist_ok=True) + + collection = {} + + for file in os.listdir(template_dir): + if file.endswith(".tar.gz"): + collection[file[:-7]] = os.path.join(template_dir, file) + + return collection + +def nodes_to_excel(nodes: list[tuple | int | bd.Node]) -> str: + """Convert a list of nodes to an HTML table suitable for Excel.""" + from .exporters import ABCSVFormatter + nodes = [refresh_node(n) for n in nodes] + databases = set(n["database"] for n in nodes) + if len(databases) > 1: + raise ValueError("All nodes must be from the same database") + db_name = databases.pop() + formatter = ABCSVFormatter(db_name, nodes) + data = formatter.get_formatted_data(sections=["activities", "exchanges"]) + + html_rows = [] + for row in data: + if isinstance(row, list): + # Bold formatting for lists with nowrap + cells = "".join(f'{str(i)}' for i in row) + else: + # Regular formatting for tuples with nowrap + cells = "".join(f'{str(i)}' for i in row) + html_rows.append(f"{cells}") + + return f"{''.join(html_rows)}
" diff --git a/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py b/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py index 181a61eab..949d43fa1 100644 --- a/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py +++ b/activity_browser/bwutils/ecoinvent_biosphere_versions/ecospold2biosphereimporter.py @@ -1,6 +1,6 @@ import os from zipfile import ZipFile -from logging import getLogger +from loguru import logger from bw2io.importers import Ecospold2BiosphereImporter from bw2io.importers.ecospold2_biosphere import EMISSIONS_CATEGORIES @@ -9,9 +9,20 @@ from activity_browser.mod import bw2data as bd from ...info import __ei_versions__ -from ...utils import sort_semantic_versions -log = getLogger(__name__) + +def sort_semantic_versions(versions, highest_to_lowest: bool = True) -> list: + """Return a sorted (default highest to lowest) list of semantic versions. + + Sorts based on the semantic versioning system. + """ + return list( + sorted( + versions, + key=lambda x: tuple(map(int, x.split("."))), + reverse=highest_to_lowest, + ) + ) def create_default_biosphere3(version) -> None: @@ -19,12 +30,12 @@ def create_default_biosphere3(version) -> None: # format version number to only Major/Minor version = version[:3] - if version == sort_semantic_versions(__ei_versions__)[0][:3]: - log.debug(f"Installing biosphere version >{version}<") + if version == __ei_versions__[0][:3]: + logger.debug(f"Installing biosphere version >{version}<") # most recent version eb = Ecospold2BiosphereImporter() else: - log.debug(f"Installing legacy biosphere version >{version}<") + logger.debug(f"Installing legacy biosphere version >{version}<") # not most recent version, import legacy biosphere from AB eb = ABEcospold2BiosphereImporter(version=version) eb.apply_strategies() @@ -56,7 +67,7 @@ def extract_flow_data(o): lci_dirpath = os.path.join(os.path.dirname(__file__), "legacy_biosphere") # find the most recent legacy biosphere that is equal to or older than chosen version - for ei_version in sort_semantic_versions(__ei_versions__): + for ei_version in __ei_versions__: use_version = ei_version fp = os.path.join( lci_dirpath, f"ecoinvent elementary flows {use_version}.xml.zip" @@ -74,7 +85,7 @@ def extract_flow_data(o): ) as file: root = objectify.parse(file).getroot() - log.debug(f"Installing biosphere {use_version} for chosen version {version}") + logger.debug(f"Installing biosphere {use_version} for chosen version {version}") flow_data = bd.utils.recursive_str_to_unicode( [extract_flow_data(ds) for ds in root.iterchildren()] ) diff --git a/activity_browser/bwutils/exporters.py b/activity_browser/bwutils/exporters.py index b8014c4ab..9ec71cf36 100644 --- a/activity_browser/bwutils/exporters.py +++ b/activity_browser/bwutils/exporters.py @@ -5,11 +5,10 @@ from typing import Union import xlsxwriter +import bw2data as bd from bw2io.export.csv import reformat from bw2io.export.excel import CSVFormatter, create_valid_worksheet_name -from activity_browser.mod import bw2data as bd - from .importers import ABPackage from .pedigree import PedigreeMatrix diff --git a/activity_browser/bwutils/filesystem.py b/activity_browser/bwutils/filesystem.py new file mode 100644 index 000000000..6fec07a8e --- /dev/null +++ b/activity_browser/bwutils/filesystem.py @@ -0,0 +1,25 @@ +import platformdirs +from pathlib import Path + +import bw2data as bd + + +def get_package_path() -> Path: + path = Path(__file__).resolve().parents[1] + path.mkdir(parents=True, exist_ok=True) + return path + +def get_appdata_path() -> Path: + path = Path(platformdirs.user_data_dir(appname="ActivityBrowser", appauthor="pylca")) + path.mkdir(parents=True, exist_ok=True) + return path + +def get_project_path() -> Path: + path = bd.projects.dir + path.mkdir(parents=True, exist_ok=True) + return path + +def get_project_ab_path() -> Path: + path = Path(bd.projects.dir) / "activity_browser" + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/activity_browser/bwutils/importers.py b/activity_browser/bwutils/importers.py index 96bafa27b..69dbb49ea 100644 --- a/activity_browser/bwutils/importers.py +++ b/activity_browser/bwutils/importers.py @@ -19,14 +19,13 @@ normalize_biosphere_names, normalize_units, set_code_by_activity_hash, strip_biosphere_exc_locations) - -from activity_browser.mod import bw2data as bd +import bw2data as bd from .errors import LinkingFailed from .strategies import (alter_database_name, csv_rewrite_product_key, hash_parameter_group, link_exchanges_without_db, relink_exchanges_bw2package, relink_exchanges_with_db, - rename_db_bw2package, parse_JSON_fields) + rename_db_bw2package, parse_JSON_fields, metadatastore_link, alter_exchange_database_name) class ABExcelImporter(ExcelImporter): @@ -127,6 +126,7 @@ def automated_import(self, db_name: str, relink: dict = None) -> list: excs = [exc for exc in self.unlinked][:10] databases = {exc.get("database", "(name missing)") for exc in self.unlinked} raise StrategyError(excs, databases) + if self.project_parameters: self.write_project_parameters(delete_existing=False) db = self.write_database(delete_existing=True, activate_parameters=True) @@ -134,6 +134,41 @@ def automated_import(self, db_name: str, relink: dict = None) -> list: bd.parameters.recalculate() return [db] + def apply_basic_strategies(self): + self.apply_strategies([ + csv_restore_tuples, + csv_restore_booleans, + csv_numerize, + csv_drop_unknown, + csv_add_missing_exchanges_section, + csv_rewrite_product_key, + normalize_units, + normalize_biosphere_categories, + normalize_biosphere_names, + strip_biosphere_exc_locations, + set_code_by_activity_hash, + drop_falsey_uncertainty_fields_but_keep_zeros, + convert_uncertainty_types_to_integers, + hash_parameter_group, + convert_activity_parameters_to_list, + parse_JSON_fields, + ]) + + def apply_db_name(self, db_name: str): + """Apply a database name change strategy.""" + self.apply_strategy( + functools.partial(alter_database_name, old=self.db_name, new=db_name) + ) + self.db_name = db_name + + def apply_linking(self, relink: dict): + self.apply_strategies([ + link_technosphere_by_activity_hash, # internal linking + functools.partial(alter_exchange_database_name, linking_dict=relink), # change db names + metadatastore_link, # link using metadatastore + ]) + + 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)): diff --git a/activity_browser/bwutils/io/ecoinvent_importer.py b/activity_browser/bwutils/io/ecoinvent_importer.py index b584f3c23..909843617 100644 --- a/activity_browser/bwutils/io/ecoinvent_importer.py +++ b/activity_browser/bwutils/io/ecoinvent_importer.py @@ -5,7 +5,7 @@ from io import BytesIO from lxml import objectify from functools import partial -from logging import getLogger +from loguru import logger import tqdm import bw2data as bd @@ -35,7 +35,7 @@ update_social_flows_in_older_consequential, ) -log = getLogger(__name__) + class Ecoinvent7zImporter: @@ -72,7 +72,7 @@ def install_ecoinvent(self, db_name, biosphere_name: str = "biosphere3"): """ # if the db already exists, warn the user of the impending overwriting and delete the existing database if db_name in bd.databases: - log.warning(f"Database already exists, overwriting {db_name}") + logger.warning(f"Database already exists, overwriting {db_name}") bd.Database(db_name).delete(warn=False) if self.is_compressed: @@ -123,7 +123,7 @@ def apply_strategies(self, db_data, biosphere_name): return db_data def read_archive_to_bytes(self) -> {str: BytesIO}: - log.info("Extracting .7z archive to memory") + logger.info("Extracting .7z archive to memory") with py7zr.SevenZipFile(self.archive_path, mode='r') as archive: # Find all .spold dataset files file_list = [ @@ -138,7 +138,7 @@ def read_archive_to_bytes(self) -> {str: BytesIO}: def process_bytes(self, spold_bytes: {str: BytesIO}, db_name: str) -> list: with multiprocessing.Pool(processes=multiprocessing.cpu_count()) as pool: - log.info(f"Extracting XML data from {len(spold_bytes)} datasets") + logger.info(f"Extracting XML data from {len(spold_bytes)} datasets") results = [ pool.apply_async( self.extract_activity, diff --git a/activity_browser/bwutils/metadata/README.md b/activity_browser/bwutils/metadata/README.md new file mode 100644 index 000000000..e3ac4b93f --- /dev/null +++ b/activity_browser/bwutils/metadata/README.md @@ -0,0 +1,54 @@ +# metadata + +Metadata management for activities, databases, and methods. + +## Overview + +This directory handles storage, retrieval, and management of metadata associated with LCI data in Activity Browser. The MetaDataStore provides quick access to reading node data. + +## Purpose + +Metadata management provides: +- **In memory** - Quicker access to ranges of nodes +- **Unpacked data blob** - Unpack the data blob from the sqlite for quick access +- **Search enhancement** - Fuzzy search capabilities on metadata fields + +## Metadata Types + +See `fields.py` for defined metadata fields and schemas. Common types include: +- **code** - Activity codes +- **name** - Activity names +- **synonyms** - Alternative names + +## Storage +Metadata is cached separately from Brightway2's native storage to allow faster access and searching. It is stored as a pickle on each flush. + +## MetaDataStore + +The `MetaDataStore` class (see `bwutils/metadata/`) provides centralized metadata access: + +```python +from activity_browser import app + +# Access metadata store +metadata = app.metadata + +# Get activity metadata +meta = metadata.get_activity_metadata(activity_key) + +# Update metadata +metadata.update_activity_metadata(activity_key, {"comment": "..."}) +``` + +## Usage Pattern + +### Reading Metadata +```python +meta = metadata.get_metadata(activity_key, fields=["name", "comment"]) +meta = metadata.get_database_metadata(database_name, fields=["description"]) +``` + +### Searching Metadata +```python +results = metadata.search(query="renewable energy") +``` diff --git a/activity_browser/bwutils/metadata/__init__.py b/activity_browser/bwutils/metadata/__init__.py index f4aa82ad7..a49f85c5e 100644 --- a/activity_browser/bwutils/metadata/__init__.py +++ b/activity_browser/bwutils/metadata/__init__.py @@ -1 +1,3 @@ -from .metadata import AB_metadata \ No newline at end of file +from .metadata import MetaDataStore + +from . import fields diff --git a/activity_browser/bwutils/metadata/fields.py b/activity_browser/bwutils/metadata/fields.py index 59b885928..8a7d6458a 100644 --- a/activity_browser/bwutils/metadata/fields.py +++ b/activity_browser/bwutils/metadata/fields.py @@ -1,25 +1,32 @@ primary_types = { "key": object, - "id": "int64", + "id": "Int64", "code": str, - "database": "category", - "location": "category", + "database": object, + "location": object, "name": str, "product": object, - "type": "category", + "type": object, } secondary_types = { "synonyms": object, - "unit": "category", - "CAS number": "category", + "unit": object, + "CAS number": object, "categories": object, "processor": object, - "allocation": "category", + "allocation": object, "allocation_factor": float, "properties": object, } + +search_engine_whitelist = [ + "id", "name", "synonyms", "unit", "key", "database", # generic + "CAS number", "categories", # biosphere specific + "product", "reference product", "classifications", "location", "properties" # activity specific + ] + all_types = {**primary_types, **secondary_types} primary = list(primary_types.keys()) secondary = list(secondary_types.keys()) -all = primary + secondary +all_fields = primary + secondary diff --git a/activity_browser/bwutils/metadata/loader.py b/activity_browser/bwutils/metadata/loader.py index c77ef3118..7533feac4 100644 --- a/activity_browser/bwutils/metadata/loader.py +++ b/activity_browser/bwutils/metadata/loader.py @@ -1,56 +1,94 @@ -import subprocess import sqlite3 -import sys import pickle -from logging import getLogger +import os +from multiprocessing import Pool +from loguru import logger from typing import Literal - import pandas as pd -import bw2data as bd -from bw2data.backends import sqlite3_lci_db -from qtpy import QtCore +from qtpy.QtCore import QObject, QThread, Signal, SignalInstance -from activity_browser import signals, application -from activity_browser.ui.core import threading +from activity_browser.bwutils.settings import Settings from .metadata import MetaDataStore -from .fields import secondary_types, primary, secondary - -log = getLogger(__name__) +from .fields import secondary_types, primary, secondary, search_engine_whitelist, all_fields -class MDSLoader(QtCore.QObject): +class MDSLoader(QObject): primary_status: Literal["idle", "loading", "done"] = "idle" secondary_status: Literal["idle", "loading", "done"] = "idle" def __init__(self, mds: MetaDataStore): - super().__init__(mds) + super().__init__(parent=mds) self.mds = mds + self.thread: QThread | None = None self.connect_signals() def connect_signals(self): - signals.project.changed.connect(self.on_project_changed) + from bw2data import signals + + # Connect to Brightway's project_changed signal + signals.project_changed.connect(self.on_project_changed) - def on_project_changed(self): + def on_project_changed(self, sender): + """Called when the Brightway project changes.""" self.load_project() def load_project(self): + import bw2data as bd + from bw2data.backends import sqlite3_lci_db + # set statuses self.primary_status = "loading" self.secondary_status = "loading" - # start loading threads - thread = SecondaryLoadThread(self) - thread.setObjectName("SecondaryLoadThread-MDSLoader") - thread.done.connect(self.secondary_load_project) - thread.start(databases=list(bd.databases), sqlite_db=str(sqlite3_lci_db._filepath)) + # check for valid cache and load from it if available + if self._has_cache() and Settings()["metadatastore"]["caching_enabled"]: + self.cache_load_project() + return + + # start loading thread for secondary metadata + self.thread = SecondaryLoadThread( + databases=list(bd.databases), + sqlite_db=str(sqlite3_lci_db._filepath), + parent=self, + ) + self.thread.result.connect(self.secondary_load_project) + self.thread.start() # load primary metadata in the main thread self.primary_load_project() + def cache_load_project(self): + from activity_browser.bwutils import filesystem + + logger.debug("Loading metadata from cache") + + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + cached_df = pd.read_pickle(cache_path) + + # quick sanity checks + if not self._cache_check(cached_df): + logger.info("Cache file is invalid or outdated, loading from database instead") + cache_path.unlink() + self.load_project() + return + + self.mds.dataframe = cached_df + + for idx in self.mds.dataframe.index: + self.mds.register_mutation(idx, "add") + + self.primary_status = "done" + self.secondary_status = "done" + + searcher_thread = InitSearcherThread(self.mds, parent=self) + searcher_thread.start() + def primary_load_project(self): + from bw2data.backends import sqlite3_lci_db + with sqlite3.connect(sqlite3_lci_db._filepath) as con: fields = ', '.join(primary[1:]) # Exclude 'key' as it's constructed primary_df = pd.read_sql(f"SELECT {fields} FROM activitydataset", con) @@ -58,7 +96,7 @@ def primary_load_project(self): primary_df["key"] = list(zip(primary_df["database"], primary_df["code"])) primary_df.index = pd.MultiIndex.from_tuples(primary_df["key"], names=["database", "code"]) - log.debug(f"Primary metadata loaded with {len(primary_df)} rows") + logger.debug(f"Primary metadata loaded with {len(primary_df)} rows") self.mds.dataframe = primary_df for idx in primary_df.index: @@ -67,28 +105,50 @@ def primary_load_project(self): self.primary_status = "done" def secondary_load_project(self, secondary_df: pd.DataFrame, sqlite_db: str): + logger.debug("secondary_load_project") + from bw2data.backends import sqlite3_lci_db + if sqlite_db != str(sqlite3_lci_db._filepath): return - assert all(secondary_df.index.isin(self.mds.dataframe.index)) - log.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") - self.mds.dataframe = pd.concat([self.mds.dataframe[primary], secondary_df], axis=1) + assert all(secondary_df.index.isin(self.mds.keys)) + logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") + left = self.mds.get_metadata(columns=primary) + + self.mds.dataframe = pd.concat([left, secondary_df], axis=1) for idx in secondary_df.index: self.mds.register_mutation(idx, "update") self.secondary_status = "done" + searcher_thread = InitSearcherThread(self.mds, parent=self) + searcher_thread.start() + def load_database(self, database_name: str): - # start loading threads - thread = SecondaryLoadThread(self) - thread.done.connect(self.secondary_load_database) - thread.start(databases=[database_name], sqlite_db=str(sqlite3_lci_db._filepath)) + from bw2data.backends import sqlite3_lci_db + self.primary_status = "loading" + self.secondary_status = "loading" + + if self.thread is not None and self.thread.isRunning(): + logger.debug("Waiting for previous loading thread to finish") + self.thread.wait() + + # start loading thread for secondary metadata + self.thread = SecondaryLoadThread( + databases=[database_name], + sqlite_db=str(sqlite3_lci_db._filepath), + parent=self, + ) + self.thread.result.connect(self.secondary_load_database) + self.thread.start() # load primary metadata in the main thread self.primary_load_database(database_name) def primary_load_database(self, database_name: str): + from bw2data.backends import sqlite3_lci_db + with sqlite3.connect(sqlite3_lci_db._filepath) as con: fields = ', '.join(primary[1:]) # Exclude 'key' as it's constructed primary_df = pd.read_sql(f"SELECT {fields} FROM activitydataset WHERE database = '{database_name}'", con) @@ -96,69 +156,188 @@ def primary_load_database(self, database_name: str): primary_df["key"] = list(zip(primary_df["database"], primary_df["code"])) primary_df.index = pd.MultiIndex.from_tuples(primary_df["key"], names=["database", "code"]) - log.debug(f"Primary metadata loaded with {len(primary_df)} rows") + logger.debug(f"Primary metadata loaded with {len(primary_df)} rows") self.mds.dataframe = pd.concat([self.mds.dataframe, primary_df]) for idx in primary_df.index: self.mds.register_mutation(idx, "add") + self.primary_status = "done" + def secondary_load_database(self, secondary_df: pd.DataFrame, sqlite_db: str): + from bw2data.backends import sqlite3_lci_db + logger.debug("Starting secondary metadata load database callback") + if secondary_df.empty or sqlite_db != str(sqlite3_lci_db._filepath): + self.secondary_status = "done" return database = secondary_df.index[0][0] - indices = self.mds.dataframe.loc[[database]].index + indices = self.mds.get_database_metadata(database, []).index if not all(secondary_df.index.isin(indices)): - log.debug("Secondary database metadata dropping rows") + logger.debug("Secondary database metadata dropping rows") secondary_df = secondary_df[secondary_df.index.isin(indices)] - log.debug(f"Secondary metadata loaded with {len(secondary_df)} rows") + logger.debug(f"Secondary metadata loaded with {len(secondary_df)} rows, adding to metadatastore {id(self.mds)}") - self._fix_categories(secondary_df) - self.mds.dataframe.update(secondary_df) + df = self.mds.dataframe + self._fix_categories(secondary_df, df) + df = secondary_df.combine_first(df) + self.mds.dataframe = df for idx in secondary_df.index: self.mds.register_mutation(idx, "update") + if self.mds.searcher is not None: + search_engine_cols = list(set(all_fields) & set(search_engine_whitelist)) + df = self.mds.get_database_metadata(database, search_engine_cols) + for col in df.select_dtypes(include=['category']).columns: + df[col] = df[col].astype(object) + self.mds.searcher.add_identifier(df) + + self.secondary_status = "done" + # utility functions - def _fix_categories(self, df: pd.DataFrame): + @staticmethod + def _fix_categories(df: pd.DataFrame, mds_df: pd.DataFrame): category_columns = [k for k, v in secondary_types.items() if v == "category"] for col in category_columns: categories = df[col].dropna().unique() - categories = [c for c in categories if c not in self.mds.dataframe[col].cat.categories] + categories = [c for c in categories if c not in mds_df[col].cat.categories] # add new category to column - self.mds.dataframe[col] = self.mds.dataframe[col].cat.add_categories(categories) + mds_df[col] = mds_df[col].cat.add_categories(categories) + def _has_cache(self) -> bool: + from activity_browser.bwutils import filesystem -class SecondaryLoadThread(threading.ABThread): - done: QtCore.SignalInstance = QtCore.Signal(pd.DataFrame, str) + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + lci_path = filesystem.get_project_path() / "lci" / "databases.db" - def run_safely(self, databases: list[str], sqlite_db: str): - processes = [self.open_load_process(db, sqlite_db) for db in databases] + if not cache_path.exists() or not lci_path.exists(): + return False - full_df = pd.DataFrame() - for proc in processes: - stdout_data, stderr_data = proc.communicate() - if proc.returncode != 0: - log.error(f"Error loading metadata: {stderr_data.decode()}") - continue - df = pickle.loads(stdout_data) - if df.empty: - continue + cache_mtime = cache_path.stat().st_mtime + lci_mtime = lci_path.stat().st_mtime - full_df = pd.concat([full_df, df]) + return cache_mtime >= lci_mtime - self.done.emit(full_df, sqlite_db) + def _cache_check(self, cached_df: pd.DataFrame) -> bool: + import bw2data as bd + from bw2data.backends import sqlite3_lci_db - def open_load_process(self, database_name: str, sqlite_db: str) -> subprocess.Popen: - import activity_browser.bwutils.metadata._sub_loader as sl + if not all(db in bd.databases for db in cached_df["database"].unique()): + logger.warning("Cache file contains databases not in the current Brightway project") + return False - return subprocess.Popen( - [sys.executable, sl.__file__, str(sqlite_db), database_name] + secondary, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + if not len(cached_df) == len(cached_df["id"].unique()): + logger.warning("Cache file contains duplicate IDs") + return False + + if cached_df.empty: + logger.warning("Cache file is empty") + return False + + with sqlite3.connect(sqlite3_lci_db._filepath) as con: + cursor = con.cursor() + cursor.execute("SELECT COUNT(*) FROM activitydataset") + count = cursor.fetchone()[0] + + if count != len(cached_df): + logger.warning("Cache file row count does not match database row count") + return False + + return True + + + +class InitSearcherThread(QThread): + """Thread for initializing the searcher.""" + + def __init__(self, mds: MetaDataStore, parent): + super().__init__(parent=parent) + self.mds = mds + + def run(self): + """Execute the searcher initialization in a background thread.""" + from .searcher import MDSSearcher + + if os.environ.get("AB_NO_SEARCHER"): + logger.debug("Skipping searcher initialization due to AB_NO_SEARCHER environment variable") + return + + if Settings()["metadatastore"]["searcher_enabled"] is False: + logger.debug("Skipping searcher initialization due to settings") + return + if self.mds.searcher is not None: + old_searcher = self.mds.searcher + self.mds.searcher = None + + # Clear large data structures + if hasattr(old_searcher, 'df'): + del old_searcher.df + if hasattr(old_searcher, 'identifier_to_word'): + del old_searcher.identifier_to_word + if hasattr(old_searcher, 'word_to_identifier'): + del old_searcher.word_to_identifier + if hasattr(old_searcher, 'word_to_q_grams'): + del old_searcher.word_to_q_grams + if hasattr(old_searcher, 'q_gram_to_word'): + del old_searcher.q_gram_to_word + + del old_searcher + + self.mds.searcher = MDSSearcher(self.mds) + + +class SecondaryLoadThread(QThread): + """Thread for loading secondary metadata using multiprocessing Pool.""" + result: SignalInstance = Signal(pd.DataFrame, str) + + def __init__(self, databases: list[str], sqlite_db: str, parent): + super().__init__(parent=parent) + self.databases = databases + self.sqlite_db = sqlite_db + + def run(self): + """Execute the loading in a background thread.""" + try: + if len(self.databases) > 1: + logger.debug(f"Loading metadata from {len(self.databases)} databases using multiprocessing Pool") + with Pool() as pool: + args = [(self.sqlite_db, db, secondary) for db in self.databases] + results = pool.starmap(load, args) + else: + logger.debug("Loading metadata from a single database without multiprocessing") + results = [load(self.sqlite_db, db, secondary) for db in self.databases] + + full_df = pd.DataFrame() + for df in results: + if df is None or df.empty: + continue + full_df = pd.concat([full_df, df]) + + except Exception as e: + logger.error(f"Error loading secondary metadata: {e}", exc_info=True) + full_df = pd.DataFrame() + + self.result.emit(full_df, self.sqlite_db) + + +def load(fp: str, database_name: str, fields: list[str]): + con = sqlite3.connect(fp) + sql = f"SELECT data FROM activitydataset WHERE database = '{database_name}'" + raw_df = pd.read_sql(sql, con) + con.close() + + df = pd.DataFrame([pickle.loads(x) for x in raw_df["data"]]) + if df.empty: + return df + + df["key"] = list(zip(df["database"], df["code"])) + df.index = pd.MultiIndex.from_tuples(df["key"], names=["database", "code"]) + df = df.reindex(columns=fields)[fields] + return df \ No newline at end of file diff --git a/activity_browser/bwutils/metadata/metadata.py b/activity_browser/bwutils/metadata/metadata.py index ffab3ad23..4d63b315c 100644 --- a/activity_browser/bwutils/metadata/metadata.py +++ b/activity_browser/bwutils/metadata/metadata.py @@ -1,26 +1,33 @@ -from time import time -from logging import getLogger -from typing import Literal +from typing import Literal, Optional +from loguru import logger -import pandas as pd - -from qtpy.QtCore import Qt, QObject, Signal, SignalInstance, QTimer - -from .fields import all, all_types +from qtpy.QtCore import QObject +import pandas as pd -log = getLogger(__name__) +from activity_browser.bwutils.settings import Settings +from .fields import all_fields, all_types class MetaDataStore(QObject): - synced: SignalInstance = Signal(set, set, set) # added, updated, deleted - + """Singleton class to manage metadata storage, loading, updating, and searching.""" + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls, *args, **kwargs) + cls._instance._initialized = False + return cls._instance + def __init__(self, parent=None): - from activity_browser import application from .loader import MDSLoader from .updater import MDSUpdater + from .searcher import MDSSearcher - super().__init__(parent) + if self._initialized: + return + self._initialized = True + super().__init__(parent=parent) self._dataframe = pd.DataFrame() @@ -30,9 +37,7 @@ def __init__(self, parent=None): self.loader = MDSLoader(self) self.updater = MDSUpdater(self) - self.flusher: QTimer | None = None - - self.moveToThread(application.thread()) + self.searcher: MDSSearcher | None = None # initialized by the loader @property def dataframe(self) -> pd.DataFrame: @@ -40,15 +45,29 @@ def dataframe(self) -> pd.DataFrame: @dataframe.setter def dataframe(self, df: pd.DataFrame) -> None: - # Ensure all expected columns are present, in the correct order, and with the correct types - df = df.reindex(columns=all)[all].astype(all_types) + # Ensure all expected columns are present, in the correct order + df = df.reindex(columns=all_fields)[all_fields] + + # Apply types carefully - avoid in-place modifications + for col, col_type in all_types.items(): + if col in df.columns: + df[col] = df[col].astype(col_type) + + # No NaN values in object columns, use None instead + for col, col_type in all_types.items(): + if col_type != object or col not in df.columns: + continue + df[col] = df[col].where(df[col].notnull(), None) - # Set the internal dataframe self._dataframe = df @property def databases(self): - return set(self.dataframe.get("database", [])) + return set(self._dataframe.index.get_level_values(0).unique().tolist()) + + @property + def keys(self): + return set(self._dataframe.index.tolist()) def register_mutation(self, key: tuple[str, str], action: Literal["add", "update", "delete"]): if action == "add": @@ -69,48 +88,186 @@ def register_mutation(self, key: tuple[str, str], action: Literal["add", "update else: raise ValueError(f"Unknown action: {action}") - if not self.flusher: - self.flusher = QTimer(self, interval=100) - self.flusher.timeout.connect(self.flush_mutations) - self.flusher.start() + def flush_mutations(self) -> tuple[set[tuple[str, str]], set[tuple[str, str]], set[tuple[str, str]]]: + from activity_browser.bwutils import filesystem - def flush_mutations(self): if not (self._added or self._updated or self._deleted): - return + return set(), set(), set() + + added = self._added.copy() + updated = self._updated.copy() + deleted = self._deleted.copy() - t = time() - self.synced.emit(self._added, self._updated, self._deleted) + self._added.clear() + self._updated.clear() + self._deleted.clear() - self._added.clear(), self._updated.clear(), self._deleted.clear() + if Settings()["metadatastore"]["caching_enabled"]: + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + self._dataframe.to_pickle(cache_path) - log.debug(f"Metadatastore sync signal completed in {time() - t:.2f} seconds") + return added, updated, deleted def match(self, **kwargs: dict[str, str]) -> pd.DataFrame: """Return a slice of the dataframe matching the criteria. """ - df = self.dataframe.query( + df = self._dataframe.query( " and ".join( [ - f"`{key}` == '{value}'" if not pd.isna(value) else f"`{key}`.isnull()" + f"`{key}`.astype('str') == {str(value)!r}" if not pd.isna(value) else f"`{key}`.isnull()" for key, value in kwargs.items() ]) ) return df - def get_metadata(self, keys: list, columns: list) -> pd.DataFrame: + def get_metadata(self, keys: list = None, columns: list = None) -> pd.DataFrame: """Return a slice of the dataframe matching row and column identifiers. NOTE: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#deprecate-loc-reindex-listlike From pandas version 1.0 and onwards, attempting to select a column with all NaN values will fail with a KeyError. """ - df = self.dataframe.loc[pd.IndexSlice[keys], :] + keys = keys if keys is not None else self._dataframe.index.tolist() + columns = columns if columns is not None else all_fields + + df = self._dataframe.loc[pd.IndexSlice[keys], :] return df.reindex(columns, axis="columns") def get_database_metadata(self, db_name: str, columns: list = None) -> pd.DataFrame: + columns = columns if columns is not None else all_fields + if db_name not in self.databases: - return pd.DataFrame(columns=all) - return self.dataframe.loc[[db_name], columns or all] + return pd.DataFrame(columns=columns or all_fields) + + df = self._dataframe.loc[[db_name], columns] + return df.reindex(columns, axis="columns") + + def _pandas_search(self, query: str, database: str = None, columns: list = None) -> pd.DataFrame: + """Fallback pandas-based search when searcher is not initialized. + + Args: + query: Search query string, may contain key:value parameters + database: Optional database name to restrict search + columns: Optional list of columns to return + + Returns: + DataFrame with matching results + """ + params, clean_query = get_query_parameters(query) + columns = columns if columns is not None else all_fields + + # Start with the full dataframe or database subset + if database and database in self.databases: + df = self._dataframe.loc[[database]] + else: + df = self._dataframe + + if not clean_query.strip(): + # If no search query, just filter by parameters + if params: + extra_query = " & ".join( + [ + f"`{key}`.astype('str').str.contains('{value}', case=False)" + for key, value in params.items() + if key in df.columns + ] + ) + if extra_query: + df = df.query(extra_query) + return df[columns] -AB_metadata = MetaDataStore() + # Search across text fields: name, product, synonyms, categories, unit, location + search_fields = ['name', 'product', 'synonyms', 'categories', 'unit', 'location', 'CAS number'] + mask = pd.Series([False] * len(df), index=df.index) + + for field in search_fields: + if field in df.columns: + # Case-insensitive search + mask |= df[field].astype(str).str.contains(clean_query, case=False, na=False) + + df = df[mask] + + # Apply additional parameter filters if any + if params: + extra_query = " & ".join( + [ + f"`{key}`.astype('str').str.contains('{value}', case=False)" + for key, value in params.items() + if key in df.columns + ] + ) + if extra_query: + df = df.query(extra_query) + + return df[columns] if columns else df + + def search(self, query: str, columns: list = None) -> pd.DataFrame: + if self.searcher: + # Advanced searcher is initialized, so use that + params, query = get_query_parameters(query) + result = self.searcher.search(query) + return self._meta_from_result(params, result, columns) + + # Fallback to simple pandas search + logger.debug("Using simple pandas search as searcher is not initialized.") + return self._pandas_search(query, columns=columns) + + def search_database(self, query: str, database: str, columns: list = None) -> pd.DataFrame: + if self.searcher: + params, query = get_query_parameters(query) + result = self.searcher.fuzzy_search(query, database=database) + return self._meta_from_result(params, result, columns) + + # Fallback to simple pandas search + logger.debug(f"Using simple pandas search for database '{database}' as searcher is not initialized.") + return self._pandas_search(query, database=database, columns=columns) + + def _meta_from_result(self, params: dict, result: list[int], columns: list = None) -> pd.DataFrame: + df = self._dataframe.loc[self.dataframe["id"].isin(result), columns or all_fields] + df.sort_values(by="id", inplace=True, key=lambda x: x.map({id_: i for i, id_ in enumerate(result)})) + + extra_query = " & ".join( + [ + f"`{key}`.astype('str').str.contains('{value}', False)" + for key, value in params.items() + if key in df.columns + ] + ) + if extra_query: + df = df.query(extra_query) + + return df + + def auto_complete(self, word: str, context: Optional[set] = None, database: Optional[str] = None): + if not self.searcher: + logger.warning(f"Attempted to search metadata before searcher was initialized.") + return [] + + word = self.searcher.clean_text(word) + completions = self.searcher.auto_complete(word, context=context, database=database) + return completions + + def clear_cache(self): + from activity_browser.bwutils import filesystem + + cache_path = filesystem.get_project_ab_path() / "metadatastore_cache.pkl" + if cache_path.exists(): + cache_path.unlink() + logger.info("Metadata store cache cleared.") + else: + logger.info("No metadata store cache found to clear.") + + +def get_query_parameters(query: str) -> tuple[dict[str, str], str]: + """Extract key-value pairs from a query string of the form 'key1:value1 key2:value2'.""" + params = {} + tokens = query.split() + clean_query = [] + for token in tokens: + if ':' in token: + key, value = token.split(':', 1) + params[key] = value + else: + clean_query.append(token) + return params, ' '.join(clean_query) diff --git a/activity_browser/bwutils/metadata/searcher.py b/activity_browser/bwutils/metadata/searcher.py new file mode 100644 index 000000000..0f3481849 --- /dev/null +++ b/activity_browser/bwutils/metadata/searcher.py @@ -0,0 +1,486 @@ +from itertools import permutations +from collections import Counter, OrderedDict +from logging import getLogger +from time import time +from typing import Optional + +import pandas as pd + +from activity_browser.bwutils.searchengine import SearchEngine + +from .metadata import MetaDataStore +from .fields import all_fields + +log = getLogger(__name__) + + +class MDSSearcher(SearchEngine): + + def __init__(self, mds: MetaDataStore): + self.mds = mds + super().__init__(self.mds.dataframe, "id", all_fields) + + # caching for faster operation + def database_id_manager(self, database): + if not hasattr(self, "all_database_ids"): + self.all_database_ids = {} + + if database_ids := self.all_database_ids.get(database): + self.database_ids = database_ids + self.current_database = database + elif database is not None: + self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) + self.all_database_ids[database] = self.database_ids + self.current_database = database + else: + # When database is None, search across all databases + if all_ids := self.all_database_ids.get(None): + self.database_ids = all_ids + else: + self.database_ids = set(self.df.index.to_list()) + self.all_database_ids[None] = self.database_ids + self.current_database = None + return self.database_ids + + def reset_database_id_manager(self): + if hasattr(self, "all_database_ids"): + del self.all_database_ids + if hasattr(self, "database_ids"): + del self.database_ids + + def database_word_manager(self, database): + if not hasattr(self, "all_database_words"): + self.all_database_words = {} + + if database_words := self.all_database_words.get(database): + self.database_words = database_words + elif database is not None: + ids = self.database_id_manager(database) + self.database_words = self.reverse_dict_many_to_one({_id: self.identifier_to_word[_id] for _id in ids}) + self.all_database_words[database] = self.database_words + else: + # When database is None, search across all databases + if all_words := self.all_database_words.get(None): + self.database_words = all_words + else: + ids = self.database_id_manager(database) + self.database_words = self.reverse_dict_many_to_one({_id: self.identifier_to_word[_id] for _id in ids}) + self.all_database_words[None] = self.database_words + return self.database_words + + def reset_database_word_manager(self, database): + if hasattr(self, "all_database_words") and self.all_database_words.get(database): + del self.all_database_words[database] + if hasattr(self, "database_words"): + del self.database_words + + def database_search_cache(self, database, query, result=None): + if not hasattr(self, "search_cache"): + self.search_cache = {} + + if result: + if self.search_cache.get(database): + self.search_cache[database][query] = result + else: + self.search_cache[database] = {query: result} + return + if db_cache := self.search_cache.get(database): + if cached_result := db_cache.get(query): + return cached_result + return + + def reset_search_cache(self, database): + if hasattr(self, "search_cache") and self.search_cache.get(database): + del self.search_cache[database] + + def reset_all_caches(self, databases): + self.reset_database_id_manager() + for database in databases: + self.reset_database_word_manager(database) + self.reset_search_cache(database) + + def add_identifier(self, data: pd.DataFrame) -> None: + super().add_identifier(data) + self.reset_all_caches(data["database"].unique()) + + def remove_identifiers(self, identifiers, logging=True) -> None: + t = time() + + identifiers = set(identifiers) + current_identifiers = set(self.df.index.to_list()) + identifiers = identifiers | current_identifiers # only remove identifiers currently in the data + databases = self.df.loc[identifiers, ["databases"]].unique() # extract databases for cache cleaning + if len(identifiers) == 0: + return + + for identifier in identifiers: + super().remove_identifier(identifier, logging=False) + + if logging: + log.debug(f"Search index updated in {time() - t:.2f} seconds " + f"for {len(identifiers)} removed items ({len(self.df)} items ({self.size_of_index()}) currently).") + self.reset_all_caches(databases) + + def change_identifier(self, identifier, data: pd.DataFrame) -> None: + super().change_identifier(identifier, data) + self.reset_all_caches(data["database"].unique()) + + def auto_complete(self, word: str, context: Optional[set] = set(), database: Optional[str] = None) -> list: + """Based on spellchecker, make more useful for autocompletions + """ + + def word_to_identifier_to_word(check_word): + if len(context) == 0: + return 1 + multiplier = 1 + for identifier in self.word_to_identifier[check_word]: + for context_word in context: + for spell_checked_context_word in spell_checked_context[context_word]: + if spell_checked_context_word in self.identifier_to_word[identifier]: + multiplier += 1 + if context_word not in self.word_to_identifier.keys(): + continue + if context_word in self.identifier_to_word[identifier]: + multiplier += 4 + return multiplier + + # count occurrences of a word, count double so word_to_identifier_to_word will never multiply by 1 + count_occurrence = lambda x: sum(self.word_to_identifier[x].values()) * 2 + + if len(word) <= 1: + return [] + + self.database_id_manager(database) + + if len(context) > 0: + spell_checked_context = {} + for context_word in context: + spell_checked_context[context_word] = self.spell_check(context_word).get(context_word, [])[:5] + + matches_min = 2 # ideally we have at least this many alternatives + matches_max = 4 # ideally don't much more than this many matches + never_accept_this = 4 # values this edit distance or over always rejected + # or max 2/3 of len(word) if less than never_accept_this + never_accept_this = int(round(max(1, min((len(word) * 0.66), never_accept_this)), 0)) + + # first, find possible matches quickly + q_grams = self.text_to_positional_q_gram(word) + possible_matches = self.find_q_gram_matches(set(q_grams), return_all=True) + + first_matches = Counter() + other_matches = {} + probably_keys = Counter() # if we suspect it's a key hash, dump it at the end of the list + + # now, refine with edit distance + for row in possible_matches.itertuples(): + if word == row[1]: + continue + # find edit distance of same size strings + edit_distance = self.osa_distance(word, row[1][:len(word)], cutoff=never_accept_this) + if len(row[1]) == 32 and edit_distance <= 1: + probably_keys[row[1]] = 100 - edit_distance # keys need to be sorted on edit distance, not on occurence + elif edit_distance == 0: + first_matches[row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) + elif edit_distance < never_accept_this and len(first_matches) < matches_min: + if not other_matches.get(edit_distance): + other_matches[edit_distance] = Counter() + other_matches[edit_distance][row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) + else: + continue + + # add matches in correct order: + matches = [match for match, _ in first_matches.most_common()] + # if we have fewer matches than goal, add more 'less good' matches + if len(matches) < matches_min: + for i in range(1, never_accept_this): + # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives + if new := other_matches.get(i): + prev_num = 10e100 + for match, num in new.most_common(): + if num == prev_num: + matches.append(match) + elif num != prev_num and len(matches) <= matches_max: + matches.append(match) + else: + break + prev_num = num + + matches = matches + [match for match, _ in probably_keys.most_common()] + return matches + + def find_q_gram_matches(self, q_grams: set, return_all: bool = False) -> pd.DataFrame: + """Overwritten for extra database specific reduction of results. + """ + n_q_grams = len(q_grams) + + matches = {} + + # find words that match our q-grams + for q_gram in q_grams: + if words := self.q_gram_to_word.get(q_gram, False): + # q_gram exists in our search index + for word in words: + if isinstance(self.database_ids, set): + # DATABASE SPECIFIC now filter on whether word is in the database + in_db = False + for _id in self.word_to_identifier[word]: + if _id in self.database_ids: + in_db = True + break + else: + in_db = True + if in_db: + matches[word] = matches.get(word, 0) + words[word] + + # if we find no results, return an empty dataframe + if len(matches) == 0: + return pd.DataFrame({"word": [], "matches": []}) + + # otherwise, create a dataframe and + # reduce search results to most relevant results + matches = {"word": matches.keys(), "matches": matches.values()} + matches = pd.DataFrame(matches) + max_q = max(matches["matches"]) # this has the most matching q-grams + + # determine how many results we want to keep based on how good our results are + if not return_all: + min_q = min(max(max_q * 0.32, # have at least a third of q-grams of best match or... + max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? + 1)), # okay just do 1 q-gram if there are no more in the word + max_q) # never have min_q be over max_q + else: + min_q = 0 + + matches = matches[matches["matches"] >= min_q] + matches = matches.sort_values(by="matches", ascending=False) + matches = matches.reset_index(drop=True) + + return matches.iloc[:min(len(matches), 2500), :] # return at most this many results + + def search_size_1(self, queries: list, original_words: set, orig_word_weight=5, exact_word_weight=1) -> dict: + """Return a dict of {query_word: Counter(identifier)}. + + queries: is a list of len 1 tuple/lists of words that are a searched word or a 'spell checked' similar word + original words: a list of words actually searched for (not including spellchecked) + + orig_word_weight: additional weight to add to original words + exact_word_weight: additional weight to add to exact word matches (as opposed to be 'in' str) + + First, we find all matching words, creating a dict of words in 'queries' as keys and words matching that query word as list of values + Next, we convert this to identifiers and add weights: + Weight will be increased if matching 'orig_word_weight' or 'exact_word_weight' + """ + matches = {} + t2 = time() + # add each word in search index if query_word in word + for word in self.database_words.keys(): + for query in queries: + # query is list/tuple of len 1 + query_word = query[0] # only use the word + if query_word in word: + words = matches.get(query_word, []) + words.extend([word]) + matches[query_word] = words + + # now convert matched words to matched identifiers + matched_identifiers = {} + for word, matching_words in matches.items(): + if result := self.database_search_cache(self.current_database, word): + matched_identifiers[word] = result + continue + id_counter = matched_identifiers.get(word, Counter()) + for matched_word in matching_words: + weight = self.base_weight + + # add the word n times, where n is the weight, original search word is weighted higher than alternatives + if matched_word in original_words: + weight += orig_word_weight # increase weight for original word + if matched_word == word: + weight += exact_word_weight # increase weight for exact matching word + + id_counter = self.weigh_identifiers(self.database_words[matched_word], weight, id_counter) + matched_identifiers[word] = id_counter + self.database_search_cache(self.current_database, word, matched_identifiers[word]) + + return matched_identifiers + + def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter: bool = False, + logging: bool = True) -> list: + """Overwritten for extra database specific reduction of results. + + Args: + text: Search query string + database: Database name to search within. If None, searches across all databases. + return_counter: If True, return a Counter instead of a list + logging: If True, log search timing information + + Returns: + List of identifiers (or Counter if return_counter=True) matching the search. + """ + t = time() + text = text.strip() + + if len(text) == 0: + log.debug(f"Empty search, returned all items") + if database: + return self.df.loc[self.df["database"] == database].index.to_list() + return self.df.index.to_list() + + # DATABASE SPECIFIC get the set of ids that is in this database + self.database_id_manager(database) + self.database_word_manager(database) + + queries = self.build_queries(text) + + # make list of unique original words + orig_words = OrderedDict() + for word in text.split(" "): + orig_words[word] = False + orig_words = orig_words.keys() + orig_words = {self.clean_text(word) for word in orig_words} + + # order the queries by the amount of words they contain + # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space + queries_by_size = OrderedDict() + longest_query = max([len(q) for q in queries]) + for query_len in range(1, longest_query + 1): + queries_by_size[query_len] = [q for q in queries if len(q) == query_len] + + # first handle queries of length 1 + query_to_identifier = self.search_size_1(queries_by_size[1], orig_words) + + # DATABASE SPECIFIC ensure all identifiers are in the database + if isinstance(self.database_ids, set): + new_q2i = {} + for word, _ids in query_to_identifier.items(): + keep = set.intersection(set(_ids.keys()), self.database_ids) + new_id_counter = Counter() + for _id in keep: + new_id_counter[_id] = _ids[_id] + if len(new_id_counter) > 0: + new_q2i[word] = new_id_counter + query_to_identifier = new_q2i + + # get all results into a df, we rank further later + all_identifiers = set() + for id_list in [id_list for id_list in query_to_identifier.values()]: + all_identifiers.update(id_list) + search_df = self.df.loc[list(all_identifiers)] + + # now, we search for combinations of query words and get only those identifiers + # we then reduce de search_df further for only those matching identifiers + # we then search the permutations of that set of words + for q_len, query_set in queries_by_size.items(): + if q_len == 1: + # we already did these above + continue + for query in query_set: + # get the intersection of all identifiers + # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query + # this ensures we only ever search data where ALL items occur to substantially reduce search-space + # finally, make this a Counter (with each item=1) so we can properly weigh things later + query_id_sets = [set(query_to_identifier.get(q_word)) for q_word in query if + query_to_identifier.get(q_word, False)] + if len(query_id_sets) == 0: + continue + query_identifier_set = set.intersection(*query_id_sets) + if len(query_identifier_set) == 0: + # there is no match for this combination of query words, skip + break + + # now we convert the query identifiers to a Counter of 'occurrence', + # where we weigh queries with only original words higher + query_identifiers = Counter() + for identifier in query_identifier_set: + weight = 0 + for query_word in query: + # if the query_word and identifier combination exist get score, otherwise 0 + weight += query_to_identifier.get(query_word, {}).get(identifier, 0) + + query_identifiers[identifier] = weight + + # we now add these identifiers to a counter for this query name, + query_name = " ".join(query) + + weight = self.base_weight * q_len + query_to_identifier[query_name] = self.weigh_identifiers(query_identifiers, weight, Counter()) + + # now search for all permutations of this query combined with a space + query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] + for query_perm in permutations(query): + query_perm_str = " ".join(query_perm) + if result := self.database_search_cache(self.current_database, query_perm_str): + new_ids = result + else: + mask = self.filter_dataframe(query_df, query_perm_str, search_columns=["query_col"]) + new_df = query_df.loc[mask].reset_index(drop=True) + if len(new_df) == 0: + # there is no match for this permutation of words, skip + continue + new_id_list = new_df[self.identifier_name] + + new_ids = Counter() + for new_id in new_id_list: + new_ids[new_id] = query_identifiers[new_id] + self.database_search_cache(self.current_database, query_perm_str, new_ids) + # we weigh a combination of words that is next also to each other even higher than just the words separately + query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, + query_to_identifier[query_name]) + # now finally, move to one object sorted list by highest score + all_identifiers = Counter() + for identifiers in query_to_identifier.values(): + all_identifiers += identifiers + + if return_counter: + return_this = all_identifiers + else: + # now sort on highest weights and make list type + return_this = [identifier[0] for identifier in all_identifiers.most_common()] + if logging: + log.debug( + f"Found {len(all_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return return_this + + def search(self, text, database: Optional[str] = None) -> list: + """Search the dataframe on this text, return a sorted list of identifiers. + + Args: + text: Search query string + database: Database name to search within. If None, searches across all databases. + + Returns: + List of identifiers matching the search, sorted by relevance. + """ + t = time() + text = text.strip() + + if len(text) == 0: + log.debug(f"Empty search, returned all items") + return self.df.index.to_list() + + # get the set of ids that is in this database + self.database_id_manager(database) + + fuzzy_identifiers = self.fuzzy_search(text, database=database, logging=False) + if len(fuzzy_identifiers) == 0: + log.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return [] + + # take the fuzzy search sub-set of data and search it literally + df = self.df.loc[fuzzy_identifiers].copy() + + literal_identifiers = self.literal_search(text, df) + if len(literal_identifiers) == 0: + log.debug( + f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return fuzzy_identifiers + + # append any fuzzy identifiers that were not found in the literal search + literal_id_set = set(literal_identifiers) + remaining_fuzzy_identifiers = [ + _id for _id in fuzzy_identifiers if _id not in literal_id_set] + identifiers = literal_identifiers + remaining_fuzzy_identifiers + + log.debug( + f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return identifiers diff --git a/activity_browser/bwutils/metadata/updater.py b/activity_browser/bwutils/metadata/updater.py index 2c969c52f..a0fff878f 100644 --- a/activity_browser/bwutils/metadata/updater.py +++ b/activity_browser/bwutils/metadata/updater.py @@ -1,35 +1,40 @@ -from logging import getLogger +from loguru import logger import pandas as pd import numpy as np -import timeit -from qtpy import QtCore - -from activity_browser import signals, application +from qtpy.QtCore import QObject from .metadata import MetaDataStore -from .fields import primary, secondary, all_types +from .fields import primary, secondary, all_types, search_engine_whitelist -log = getLogger(__name__) +class MDSUpdater(QObject): -class MDSUpdater(QtCore.QObject): def __init__(self, mds: MetaDataStore): - super().__init__(mds) - + super().__init__(parent=mds) self.mds = mds self.connect_signals() def connect_signals(self): - signals.node.changed.connect(self.on_node_changed) - signals.node.deleted.connect(self.on_node_deleted) - - signals.meta.databases_changed.connect(self.on_database_changed) - signals.database.deleted.connect(self.on_database_changed) + from bw2data import signals + from bw2data.meta import databases + + # Connect to Brightway signals + signals.signaleddataset_on_save.connect(self.on_signaleddataset_save) + signals.signaleddataset_on_delete.connect(self.on_signaleddataset_delete) + signals.on_database_delete.connect(self.on_database_deleted_bw) + databases._save_signal.connect(self.on_databases_metadata_change) # callbacks - def on_node_changed(self, new, old): + def on_signaleddataset_save(self, sender, old, new): + """Called when a dataset is created or modified in Brightway.""" + from bw2data.backends import ActivityDataset + + # Only process ActivityDataset (nodes), not exchanges or parameters + if not isinstance(new, ActivityDataset): + return + node_data = {f: getattr(new, f) for f in primary} node_data = node_data | {f: new.data.get(f, np.NaN) for f in secondary} node_data["key"] = new.key @@ -37,15 +42,32 @@ def on_node_changed(self, new, old): if new.key in self.mds.dataframe.index and not all(node_data.dropna().eq(self.mds.dataframe.loc[new.key].dropna())): self.modify_node(node_data) - else: + elif new.key not in self.mds.dataframe.index: self.add_node(node_data) - def on_node_deleted(self, ds): + def on_signaleddataset_delete(self, sender, old): + """Called when a dataset is deleted in Brightway.""" + from bw2data.backends import ActivityDataset + + # Only process ActivityDataset (nodes), not exchanges or parameters + if not isinstance(old, ActivityDataset): + return + try: + # Create a Series with the key to match the delete_node signature + ds = pd.Series({"key": old.key, "id": old.id}, name=old.key) self.delete_node(ds) except KeyError: pass + def on_database_deleted_bw(self, sender, name): + """Called when a database is deleted in Brightway.""" + self.delete_database(name) + + def on_databases_metadata_change(self, sender, old, new): + """Called when the databases metadata changes (e.g., new database added).""" + self.on_database_changed() + def on_database_changed(self) -> None: databases = databases_in_sqlite() @@ -57,31 +79,73 @@ def on_database_changed(self) -> None: # node methods def modify_node(self, ds: pd.Series): - self._fix_categories(ds) - self.mds.dataframe.loc[ds.key] = ds + df = self.mds.dataframe + self._fix_categories(ds, df) + df.loc[ds.key] = ds + + self.mds.dataframe = df self.mds.register_mutation(ds.key, "update") + if not hasattr(self.mds, "searcher") or self.mds.searcher is None: + return + + search_engine_cols = list(set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns + data = pd.DataFrame([ds[search_engine_cols]]) + self.mds.searcher.change_identifier(identifier=ds["id"], data=data) + def add_node(self, ds: pd.Series): - self._fix_categories(ds) - self.mds.dataframe.loc[ds.key, :] = ds + + df = self.mds.dataframe + self._fix_categories(ds, df) + df.loc[ds.key, :] = ds + + self.mds.dataframe = df self.mds.register_mutation(ds.key, "add") + if self.mds.searcher is None: + return + + search_engine_cols = list(set(ds.keys()) & set(search_engine_whitelist)) # intersection becomes columns + data = pd.DataFrame([ds[search_engine_cols]]) + self.mds.searcher.add_identifier(data=data) + def delete_node(self, ds: pd.Series): self.mds.dataframe = self.mds.dataframe.drop(ds.key) self.mds.register_mutation(ds.key, "delete") + if self.mds.searcher is None: + return + + node_id = ds["id"] + + self.mds.searcher.remove_identifier(identifier=node_id) + self.mds.searcher.reset_all_caches(ds["database"]) + # database methods def add_database(self, db_name: str): self.mds.loader.load_database(db_name) def delete_database(self, db_name: str): + if db_name not in self.mds.databases: + return + for code in self.mds.dataframe.loc[db_name].index: self.mds.register_mutation((db_name, code), "delete") + ids = self.mds.get_database_metadata(db_name, ["id"])["id"].tolist() + self.mds.dataframe = self.mds.dataframe.drop(db_name, level=0) + if self.mds.searcher is None: + return + + for node_id in ids: + self.mds.searcher.remove_identifier(identifier=node_id) + self.mds.searcher.reset_all_caches(db_name) + # utility functions - def _fix_categories(self, ds: pd.Series): + @staticmethod + def _fix_categories(ds: pd.Series, mds_df: pd.DataFrame): for category_col in [k for k, v in all_types.items() if k in ds and v == "category"]: category = ds[category_col] @@ -89,12 +153,12 @@ def _fix_categories(self, ds: pd.Series): # cannot add NaN as a category continue - if category in self.mds.dataframe[category_col].cat.categories: + if category in mds_df[category_col].cat.categories: # category already exists continue # add new category to column - self.mds.dataframe[category_col] = self.mds.dataframe[category_col].cat.add_categories([category]) + mds_df[category_col] = mds_df[category_col].cat.add_categories([category]) diff --git a/activity_browser/bwutils/montecarlo.py b/activity_browser/bwutils/montecarlo.py index 09b4e91df..c6b96a952 100644 --- a/activity_browser/bwutils/montecarlo.py +++ b/activity_browser/bwutils/montecarlo.py @@ -1,19 +1,13 @@ from collections import defaultdict from time import time from typing import Optional, Union -from logging import getLogger +from loguru import logger +import bw2data as bd import bw2calc as bc import bw2data as bd import numpy as np import pandas as pd -from stats_arrays import MCRandomNumberGenerator - -from activity_browser.mod import bw2data as bd - -from .manager import MonteCarloParameterManager - -log = getLogger(__name__) class MonteCarloLCA(object): @@ -87,8 +81,8 @@ def construct_lca( characterization: bool = True, seed_override: Optional[int] = None, ) -> bc.MultiLCA: - log.info(f"Monte Carlo demands: {demands}") - log.info(f"Monte Carlo impact categories: {method_config}") + logger.info(f"Monte Carlo demands: {demands}") + logger.info(f"Monte Carlo impact categories: {method_config}") demands = { index: {bd.get_activity(k).id: v for k, v in fu.items()} for index, fu in demands.items() @@ -307,7 +301,7 @@ def calculate(self, iterations: int = 10, seed: Optional[int] = None, **kwargs): # self.lca.lcia_calculation() self.results[iteration, int(row), col] = self.lca.scores[(m, row)] - log.info( + logger.info( f"Monte Carlo LCA: finished {iterations} iterations for {len(self.func_units)} reference flows and " f"{len(self.methods)} methods in {np.round(time() - start, 2)} seconds." ) @@ -331,10 +325,10 @@ def get_results_by(self, act_key=None, method=None): if act_key: act_index = self.activity_index.get(act_key) - log.info(f"Activity key provided: {act_key} {act_index}") + logger.info(f"Activity key provided: {act_key} {act_index}") if method: method_index = self.method_index.get(method) - log.info(f"Method provided: {method} {method_index}") + logger.info(f"Method provided: {method} {method_index}") if not act_key and not method: return self.results @@ -393,7 +387,7 @@ def get_labels( def perform_MonteCarlo_LCA(project="default", cs_name=None, iterations=10): """Performs Monte Carlo LCA based on a calculation setup and returns the Monte Carlo LCA object.""" - log.info(f"-- Monte Carlo LCA --\n Project: {project} CS: {cs_name}") + logger.info(f"-- Monte Carlo LCA --\n Project: {project} CS: {cs_name}") bd.projects.set_current(project, update=False) # perform Monte Carlo simulation diff --git a/activity_browser/bwutils/multilca.py b/activity_browser/bwutils/multilca.py index 9d709443b..7096de158 100644 --- a/activity_browser/bwutils/multilca.py +++ b/activity_browser/bwutils/multilca.py @@ -1,21 +1,22 @@ from collections import OrderedDict from copy import deepcopy from typing import Iterable, Optional, Union -from logging import getLogger +from loguru import logger +import bw2data as bd import bw2calc as bc import numpy as np import pandas as pd -from qtpy.QtWidgets import QApplication, QMessageBox -from activity_browser.mod import bw2data as bd from activity_browser.mod.bw2analyzer import ABContributionAnalysis from .commontasks import wrap_text from .errors import ReferenceFlowValueError -from .metadata import AB_metadata +from .metadata import MetaDataStore + +metadata = MetaDataStore() + -log = getLogger(__name__) ca = ABContributionAnalysis() @@ -110,6 +111,8 @@ class MLCA(object): """ def __init__(self, cs_name: str, lca_class: bc.LCA = bc.LCA): + from qtpy.QtWidgets import QApplication, QMessageBox + try: cs = bd.calculation_setups[cs_name] except KeyError: @@ -346,14 +349,16 @@ class Contributions(object): DEFAULT_EF_AGGREGATES = ["none"] + DEFAULT_EF_FIELDS def __init__(self, mlca): + from activity_browser.app import metadata + if not isinstance(mlca, MLCA): raise ValueError("Must pass an MLCA object. Passed:", type(mlca)) self.mlca = mlca # Set default metadata keys (those not in the dataframe will be eliminated) - self.act_fields = [fn for fn in self.DEFAULT_ACT_FIELDS if fn in AB_metadata.dataframe.columns] - self.ef_fields = [fn for fn in self.DEFAULT_EF_FIELDS if fn in AB_metadata.dataframe.columns] + self.act_fields = [fn for fn in self.DEFAULT_ACT_FIELDS if fn in metadata.dataframe.columns] + self.ef_fields = [fn for fn in self.DEFAULT_EF_FIELDS if fn in metadata.dataframe.columns] # Specific datastructures for retrieving relevant MLCA data # inventory: inventory, reverse index, metadata keys, metadata fields @@ -503,10 +508,10 @@ def get_labels( translated_keys.append(k) elif isinstance(k, str): translated_keys.append(k) - elif k in AB_metadata.dataframe.index: + elif k in metadata.dataframe.index: translated_keys.append( separator.join( - [str(l) for l in list(AB_metadata.get_metadata(k, fields))] + [str(l) for l in list(metadata.get_metadata(k, fields))] ) ) else: @@ -553,11 +558,11 @@ def join_df_with_metadata( df.index.names = ["database", "code"] # get metadata for rows - keys = [k for k in df.index if k in AB_metadata.dataframe.index] - metadata = AB_metadata.get_metadata(keys, x_fields).astype(object) + keys = [k for k in df.index if k in metadata.dataframe.index] + meta = metadata.get_metadata(keys, x_fields).astype(object) # join data with metadata - joined = metadata.join(df, how="outer") + joined = meta.join(df, how="outer") if special_keys: # replace index keys with labels @@ -565,7 +570,7 @@ def join_df_with_metadata( complete_index = special_keys + keys joined = joined.reindex(complete_index, axis="index", fill_value=0.0) except: - log.error( + logger.error( "Could not put 'Total', 'Rest (+)' and 'Rest (-)' on positions 0, 1 and 2 in the dataframe." ) joined.index = cls.get_labels(joined.index, fields=x_fields) @@ -651,7 +656,7 @@ def _build_inventory( data.columns = Contributions.get_labels(columns, max_length=30) data = pd.merge( - AB_metadata.dataframe[fields], data, right_index=True, left_on="id", how="right" + metadata.dataframe[fields], data, right_index=True, left_on="id", how="right" ) data.reset_index(inplace=True, drop=True) @@ -766,9 +771,9 @@ def aggregate_by_parameters( df = pd.DataFrame(contributions).T columns = list(range(contributions.shape[0])) df.index = rev_index.values() - metadata = AB_metadata.dataframe.loc[AB_metadata.dataframe["id"].isin(keys), fields + ["id"]] + meta = metadata.dataframe.loc[metadata.dataframe["id"].isin(keys), fields + ["id"]] - joined = metadata.merge(df, left_on="id", right_index=True, how="left") + joined = meta.merge(df, left_on="id", right_index=True, how="left") joined.reset_index(inplace=True, drop=True) grouped = joined.groupby(parameters, observed=False) aggregated = grouped[columns].sum() @@ -801,7 +806,7 @@ def _correct_method_index(self, mthd_indx: list) -> dict: conv_dict[mthd] = v return conv_dict - def _contribution_index_cols(self, **kwargs) -> (dict, Optional[Iterable]): + def _contribution_index_cols(self, **kwargs) -> tuple[dict, Optional[Iterable]]: if kwargs.get("method") is not None: return self.mlca.fu_index, self.act_fields return self._correct_method_index(self.mlca.methods), None diff --git a/activity_browser/bwutils/searchengine/__init__.py b/activity_browser/bwutils/searchengine/__init__.py new file mode 100644 index 000000000..a3ed1d8e1 --- /dev/null +++ b/activity_browser/bwutils/searchengine/__init__.py @@ -0,0 +1,2 @@ +from .base import SearchEngine +from .metadata_search import MetaDataSearchEngine diff --git a/activity_browser/bwutils/searchengine/base.py b/activity_browser/bwutils/searchengine/base.py new file mode 100644 index 000000000..0145e428e --- /dev/null +++ b/activity_browser/bwutils/searchengine/base.py @@ -0,0 +1,779 @@ +import itertools +import functools +import re +from collections import Counter, OrderedDict, defaultdict +from typing import Iterable, Optional +from time import time + +from loguru import logger + +import pandas as pd +import numpy as np + + +class SearchEngine: + """ + A Search Engine class, takes a dataframe and makes it searchable. + + A search requires a string, and will return a list of unique identifiers in the dataframe. + There are three options for search: + SearchEngine.literal_search(): searches for exact matches of the search query + SearchEngine.fuzzy_search(): searches for approximate matches of search query, sorted by relevance + SearchEngine.search(): combines both of the above, literal matches are returned first, next all fuzzy results, + but subsets sorted by relevance. + It is recommended to always use searchEngine.search(), but the other options are there. + + Initialization takes: + df: Dataframe that needs to be searchable. + identifier_name: values in this column will be returned as search results, all values in this column need to be unique. + searchable_columns: these columns need to be searchable, if none are given, all columns will be made searchable. + + Updating data is possible as well: + add_identifier(): adds this identifier to the searchable data + remove_identifier(): removes this identifier from the searchable data + change_identifier(): changes this identifier (wrapper for remove_identifier and add_identifier) + + """ + + def __init__(self, df: pd.DataFrame, identifier_name: str, searchable_columns: list = []): + t = time() + logger.debug(f"SearchEngine initializing for {len(df)} items") + + # compile regex patterns for cleaning + self.SUB_END_PATTERN = re.compile(r"[,.\"'`)\[\]}\\/\-−_:;+…]+(?=\s|$)") # remove these from end of word + self.SUB_START_PATTERN = re.compile(r"(?:^|\s)[,.\"'`(\[{\\/\-−_:;+]+") # remove these from start of word + self.ONE_SPACE_PATTERN = re.compile(r"\s+") # remove these multiple whitespaces + + self.q = 2 # character length of q grams + self.base_weight = 10 # base weighting for sorting results + + if identifier_name not in df.columns: # make sure identifier col exist + raise NameError(f"Identifier column {identifier_name} not found in dataframe. Use an existing column name.") + if df[identifier_name].nunique() != df.shape[0]: # make sure identifiers are all unique + raise KeyError( + f"Identifier column {identifier_name} must only contain unique values. Found {df[identifier_name].nunique()} unique values for length {df.shape[0]}") + + self.identifier_name = identifier_name + + # ensure columns given actually exist + # always ensure "identifier" is present + if searchable_columns == []: + # if no list is given, assume all columns are searchable + self.columns = list(df.columns) + else: + # create subset of columns to be searchable, discard rest + self.columns = [col for col in searchable_columns if col in df.columns] + if self.identifier_name not in self.columns: # keep identifier col + self.columns.append(self.identifier_name) + df = df[self.columns] + # set the identifier column as index + df = df.set_index(self.identifier_name, drop=False) + + # convert all data to str + df = df.astype(str) + + # find the self.identifier_name column index and store as int + self.identifier_column = self.columns.index(self.identifier_name) + + # store all searchable column indices except the identifier + self.searchable_columns = [i for i in range(len(self.columns)) if i != self.identifier_column] + + # initialize search index dicts and update df + self.identifier_to_word = {} + self.word_to_identifier = {} + self.word_to_q_grams = {} + self.q_gram_to_word = {} + self.df = pd.DataFrame() + + self.update_index(df) + + logger.debug(f"SearchEngine Initialized in {time() - t:.2f} seconds") + + # +++ Utility functions + + def update_index(self, update_df: pd.DataFrame) -> None: + """Update search index dicts and the df.""" + + def update_dict(update_me: dict, new: dict) -> dict: + """Update a dict of counters with new dict of counters.""" + # set to empty set if we know update_me is empty, otherwise, find set intersection + update_keys = set() if len(update_me) == 0 else new.keys() & update_me.keys() + if len(update_keys) == 0: + new_data = new + else: + for update_key in update_keys: + update_me[update_key].update(new[update_key]) + new_data = {key: value for key, value in new.items() if key not in update_keys} + # finally add any completely new data + # update_me.update(new_data) + update_me = update_me | new_data + return update_me + + if len(update_df) == 0: + return + + t = time() + size_old = len(self.df) + # identifier to word and df + i2w, update_df = self.words_in_df(update_df) + self.identifier_to_word = update_dict(self.identifier_to_word, i2w) + self.df = pd.concat([self.df, update_df]) + # word to identifier + w2i = self.reverse_dict_many_to_one(i2w) + self.word_to_identifier = update_dict(self.word_to_identifier, w2i) + # word to q-gram + w2q = self.list_to_q_grams(w2i.keys()) + self.word_to_q_grams = update_dict(self.word_to_q_grams, w2q) + # q-gram to word + q2w = self.reverse_dict_many_to_one(w2q) + self.q_gram_to_word = update_dict(self.q_gram_to_word, q2w) + size_new = len(self.df) + size_dif = size_new - size_old + logger.debug(f"Search index updated in {time() - t:.2f} seconds.") + + def clean_text(self, text: str): + """Clean a string so it doesn't contain weird characters or multiple spaces etc.""" + text = text.lower() + text = self.SUB_END_PATTERN.sub("", text) + text = self.SUB_START_PATTERN.sub(" ", text) + + text = self.ONE_SPACE_PATTERN.sub(" ", text).strip() + return text + + def text_to_positional_q_gram(self, text: str) -> list: + """Return a positional list of q-grams for the given string. + + q-grams are n-grams on character level. + q-grams at q=2 of "word" would be "wo", "or" and "rd" + https://en.wikipedia.org/wiki/N-gram + + Note: these are technically _positional_ q-grams, but we don't use their positions currently. + """ + q = self.q + n = len(text) + # just return a single-item list if the text is equal or shorter than q + # else, generate q-grams + if n <= q: + return [text] + return list(text[i:i + q] for i in range(n - q + 1)) + + def df_clean(self, df): + """Clean the text in query_col. + + apply multi-processing when the computer is able and its relevant + """ + df["query_col"] = df["query_col"].apply(self.clean_text) + return df + + def words_in_df(self, df: pd.DataFrame = None) -> tuple[dict, pd.DataFrame]: + """Return a dict of {identifier: word} for df.""" + + df = df if df is not None else self.df.copy() + df = df.fillna("") # avoid nan + # assemble query_col + df["query_col"] = df.iloc[:, self.searchable_columns].astype(str).agg(" | ".join, axis=1) + # clean all text at once using vectorized operations + df["query_col"] = self.df_clean(df.loc[:, ["query_col"]]) + # build the identifier_word_dict dictionary - filter out empty strings + identifier_word_dict = df["query_col"].apply( + lambda text: Counter(word for word in text.split(" ") if word) + ).to_dict() + return identifier_word_dict, df + + def reverse_dict_many_to_one(self, dictionary: dict) -> dict: + """Reverse a dictionary of Counter objects.""" + reverse = defaultdict(Counter) + for identifier, counter_object in dictionary.items(): + if not isinstance(counter_object, Counter): + logger.warning(f"Skipping non-Counter object for {identifier}: {type(counter_object)}") + continue + for countable, count in counter_object.items(): + if countable: # skip empty strings + reverse[countable][identifier] += count + return dict(reverse) + + def list_to_q_grams(self, word_list: Iterable) -> dict: + """Convert a list of unique words to a dict with Counter objects. + + Number will be the occurrences of that q-gram in that word. + + return = { + "word": Counter( + "wo": 1 + "or": 1 + "rd": 1 + ), + ... + } + """ + text_to_q_gram = self.text_to_positional_q_gram + return { + word: Counter(text_to_q_gram(word)) + for word in word_list + } + + def word_in_index(self, word: str) -> bool: + """Convenience function to check if a single word is in the search index.""" + if " " in word: + raise Exception( + f"Given word '{word}' must not contain spaces.") + return word in self.word_to_identifier.keys() + + # +++ Changes to searchable data + + def add_identifier(self, data: pd.DataFrame) -> None: + """Add this data to the search index. + + identifier column is REQUIRED to be present + ALL data in the given dataframe will be added, if columns should not be added, they should be removed before + calling this function + """ + + # ensure we have identifier column + if self.identifier_name not in data.columns: + raise Exception( + f"Identifier column '{self.identifier_name}' not in new data, impossible to add data without identifier") + + # make sure we the new identifiers do not yet exist + existing_ids = set(self.df.index.to_list()) + for identifier in data[self.identifier_name]: + if identifier in existing_ids: + raise Exception( + f"Identifier '{identifier}' is already in use, use a different identifier or use the change_identifier function.") + + # make sure all new identifiers given are unique + if data[self.identifier_name].nunique() != data.shape[0]: + raise KeyError( + f"Identifier column {self.identifier_name} must only contain unique values. Found {data[self.identifier_name].nunique()} unique values for length {data.shape[0]}") + + df_cols = self.columns + # add cols to new data that are missing + for col in df_cols: + if col not in data.columns: + data.loc[:, col] = [""] * len(data) + # re-order cols, first existing, then new + df_col_set = set(df_cols) + new_cols = [col for col in data.columns if col not in self.columns if col not in df_col_set] + data_cols = df_cols + new_cols + data = data[data_cols] # re-order new data to be in correct order + + # add cols from new data to correct places + self.columns.extend(new_cols) + self.searchable_columns.extend([i for i, col in enumerate(data_cols) if col in new_cols]) + + # convert df + data = data.set_index(self.identifier_name, drop=False) + data = data.astype(object).fillna("") + data = data.astype(str) + + # update the search index data + self.update_index(data) + + def remove_identifier(self, identifier, logging=True) -> None: + """Remove this identifier from self.df and the search index. + """ + if logging: + t = time() + + # make sure the identifier exists + if identifier not in self.df.index.to_list(): + logger.warning( + f"Identifier '{identifier}' does not exist in the search data, cannot remove identifier that do not exist." + ) + return + + self.df = self.df.drop(identifier) + + # find words that may need to be removed + words = self.identifier_to_word[identifier] + for word in words: + if len(self.word_to_identifier[word]) == 1: + # this word is only found in this identifier, + # remove the word and check for q grams + del self.word_to_identifier[word] + + q_grams = self.word_to_q_grams[word] + for q_gram in q_grams: + if len(self.q_gram_to_word[q_gram]) == 1: + # this q_gram is only used in this word, + # remove it + del self.q_gram_to_word[q_gram] + elif len(self.q_gram_to_word[q_gram]) > 1: + # this q_gram is used in multiple words, only remove the word from the q_gram + del self.q_gram_to_word[q_gram][word] + + del self.word_to_q_grams[word] + else: + # this word is found in multiple identifiers + # word_to_q_gram and q_gram_to_word do not need to be changed, the word still exists + # remove the identifier the word in word_to_identifier + del self.word_to_identifier[word][identifier] + # finally, remove the identifier + del self.identifier_to_word[identifier] + + if logging: + logger.debug(f"Search index updated in {time() - t:.2f} seconds " + f"for 1 removed item ({len(self.df)}.") + + def change_identifier(self, identifier, data: pd.DataFrame) -> None: + """Change this identifier. + + identifier must be an identifier that is in use + data must be a dataframe of 1 row with all change data + data is overwritten with the new data in 'data', columns not given remain unchanged + """ + + # make sure only 1 change item is given + if len(data) > 1 or len(data) < 1: + raise Exception( + f"change data must be for exactly 1 identifier, but {len(data)} items were given.") + # make sure correct use of identifier + if identifier not in self.df.index.to_list(): + raise Exception( + f"Identifier '{identifier}' does not exist in the search data, use an existing identifier or use the add_identifier function.") + if self.identifier_name in data.columns and data[self.identifier_name].to_list() != [identifier]: + raise Exception( + "Identifier field cannot be changed, first remove item and then add new identifier") + if "query_col" in data.keys(): + logger.debug( + f"Field 'query_col' is a protected field for search engine and will be ignored for changing {identifier}") + + + # overwrite new data where relevant + update_data = self.df.loc[[identifier], self.columns] + data = data.reset_index(drop=True) + for col in data.columns: + value = data.loc[0, col] + update_data[col] = [value] + + # remove the entry + self.remove_identifier(identifier, logging=False) + # add entry with updated data + self.add_identifier(update_data) + + # +++ Search + + def filter_dataframe(self, df: pd.DataFrame, pattern: str, search_columns: Optional[list] = None) -> pd.Series: + """Filter the search columns of a dataframe on a pattern. + + Returns a mask (true/false) pd.Series with matching items.""" + + search_columns = search_columns if search_columns else self.columns + mask = functools.reduce( + np.logical_or, + [ + df[col].apply(lambda x: pattern in x.lower()) + for col in search_columns + ], + ) + return mask + + def literal_search(self, text, df: Optional[pd.DataFrame] = None) -> list: + """Do literal search of the text in all original columns that were given.""" + + if df is None: + df = self.df.copy() + + identifiers = self.filter_dataframe(df, text) + df = df.loc[identifiers] + identifiers = df.index.to_list() + return identifiers + + def osa_distance(self, word1: str, word2: str, cutoff: int = 0, cutoff_return: int = 1000) -> int: + """Calculate the Optimal String Alignment (OSA) edit distance between two strings, return edit distance. + + Has additional cutoff variable, if cutoff is higher than 0 and if the words have + a larger edit distance, return a large number (note: cutoff <= edit_dist, not cutoff < edit_dist) + + OSA is a restricted form of the Damerau–Levenshtein distance. + https://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance#Optimal_string_alignment_distance + + The edit distance is how many operations (insert, delete, substitute or transpose a character) need to happen to convert one string to another. + insert and delete are obvious operations, but substitute and transpose are explained: + substitute: replace one character with another: e.g. word1='cat' word2='cab', 't'->'b' substitution is 1 operation + transpose: swap the places of two adjacent characters with each other: e.g. word1='coal' word2='cola' 'al' -> 'la' transposition is 1 operation + + The minimum amount of edit operations (OSA edit distance) is returned. + """ + if word1 == word2: + # if the strings are the same, immediately return 0 + return 0 + + len1, len2 = len(word1), len(word2) + + if 0 < cutoff <= abs(len1 - len2): + # if the length difference between 2 words is over the cutoff, + # just return instead of calculating the edit distance + return cutoff_return + + if len1 == 0 or len2 == 0: + # in case (at least) one of the strings is empty, + # return the length of the longest string + return max(len1, len2) + + if len1 < len2 and cutoff > 0: + # make sure word1 is always the longest (required for early stopping with cutoff) + word1, word2 = word2, word1 + len1, len2 = len2, len1 + + # Initialize matrix + distance = [[0] * len2 for _ in range(len1)] + + # calculate shortest edit distance + for i in range(len1): + for j in range(len2): + cost = 0 if word1[i] == word2[j] else 1 + + # Compute distances for insertion, deletion and substitution + insertion = distance[i][j - 1] + 1 if j > 0 else i + 1 + deletion = distance[i - 1][j] + 1 if i > 0 else j + 1 + substitution = distance[i - 1][j - 1] + cost if i > 0 and j > 0 else max(i, j) + cost + + distance[i][j] = min(deletion, insertion, substitution) + + # Compute transposition when relevant + if i > 0 and j > 0 and word1[i] == word2[j - 1] and word1[i - 1] == word2[j]: + transposition = distance[i - 2][j - 2] + 1 if i > 1 and j > 1 else max(i, j) - 1 + distance[i][j] = min(distance[i][j], transposition) + + # stop early if we surpass cutoff + if 0 < cutoff <= min(distance[i]): + return cutoff_return + return distance[i][j] + + def find_q_gram_matches(self, q_grams: set) -> pd.DataFrame: + """Find which of the given q_grams exist in self.q_gram_to_word, + return a sorted dataframe of best matching words. + """ + n_q_grams = len(q_grams) + + matches = {} + + # find words that match our q-grams + for q_gram in q_grams: + if words := self.q_gram_to_word.get(q_gram, False): + # q_gram exists in our search index + for word in words: + matches[word] = matches.get(word, 0) + words[word] + + # if we find no results, return an empty dataframe + if len(matches) == 0: + return pd.DataFrame({"word": [], "matches": []}) + + # otherwise, create a dataframe and + # reduce search results to most relevant results + matches = {"word": matches.keys(), "matches": matches.values()} + matches = pd.DataFrame(matches) + max_q = max(matches["matches"]) # this has the most matching q-grams + + # determine how many results we want to keep based on how good our results are + min_q = min(max(max_q * 0.32, # have at least a third of q-grams of best match or... + max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? + 1)), # okay just do 1 q-gram if there are no more in the word + max_q) # never have min_q be over max_q + + matches = matches[matches["matches"] >= min_q] + matches = matches.sort_values(by="matches", ascending=False) + matches = matches.reset_index(drop=True) + + return matches.iloc[:min(len(matches), 2500), :] # return at most this many results + + def spell_check(self, text: str, skip_len=1) -> OrderedDict: + """Create an OrderedDict of each word in the text (space separated) + with as values possible alternatives. + + Alternatives are first found with q-grams, then refined with string edit distance + + We rank alternative words based on 1) edit distance 2) how often a word is used in an entry + If too many results are found, we only keep edit distance 1, + if we want more results, we keep with longer edit distance up to `never_accept_this` + + word_results = OrderedDict( + "word": [work] + ) + + NOTE: only ALTERNATIVES are ever returned, this function returns empty list for item BOTH when + 1) the exact word is in the data + 2) when there are no suitable alternatives + """ + count_occurence = lambda x: sum(self.word_to_identifier[x].values()) # count occurences of a word + + word_results = OrderedDict() + + matches_min = 3 # ideally we have at least this many alternatives + matches_max = 10 # ideally don't much more than this many matches + always_accept_this = 1 # values of this edit distance or lower always accepted + never_accept_this = 4 # values this edit distance or over always rejected + + # make list of unique words + text = self.clean_text(text) + words = OrderedDict() + for word in text.split(" "): + if len(word) != 0: + words[word] = False + words = words.keys() + + for word in words: + if len(word) <= skip_len: # dont look for alternatives for text this short + word_results[word] = [] + continue + + # reduce acceptable edit distance with short words + dont_accept = int(round(max(1, min((len(word) * 0.66), never_accept_this)), 0)) + + # first, find possible matches quickly + q_grams = self.text_to_positional_q_gram(word) + possible_matches = self.find_q_gram_matches(set(q_grams)) + + first_matches = Counter() + other_matches = {} + + # now, refine with edit distance + for row in possible_matches.itertuples(): + + edit_distance = self.osa_distance(word, row[1], cutoff=dont_accept) + + if edit_distance == 0: + continue # we are looking for alternatives only, not the exact word + elif edit_distance <= always_accept_this: + first_matches[row[1]] = count_occurence(row[1]) + elif edit_distance < dont_accept: + if not other_matches.get(edit_distance): + other_matches[edit_distance] = Counter() + other_matches[edit_distance][row[1]] = count_occurence(row[1]) + else: + continue + + # add matches in correct order: + matches = [match for match, _ in first_matches.most_common()] + # if we have fewer matches than goal, add more 'less good' matches + if len(matches) < matches_min: + for i in range(always_accept_this + 1, dont_accept): + # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives + if new := other_matches.get(i): + prev_num = 10e100 + for match, num in new.most_common(): + if num == prev_num: + matches.append(match) + elif num != prev_num and len(matches) <= matches_max: + matches.append(match) + else: + break + prev_num = num + + word_results[word] = matches + return word_results + + def build_queries(self, query_text) -> list: + """Make all possible subsets of words in the query, including alternative words.""" + query_text = self.spell_check(query_text) + + # find all combinations of the query words as given + queries = list(query_text.keys()) + subsets = list(itertools.chain.from_iterable( + (itertools.combinations( + queries, r) for r in range(1, len(queries) + 1)))) + all_queries = [] + + for combination in subsets: + # add the 'default' option + all_queries.append(combination) + # now add all options with all alternatives + for i, word in enumerate(combination): + for alternative in query_text.get(word, []): + alternative_combination = list(combination) + alternative_combination[i] = alternative + all_queries.append(alternative_combination) + + return all_queries + + def weigh_identifiers(self, identifiers: Counter, weight: int, weighted_ids: Counter) -> Counter: + """Add weights to identifier counter for these identifiers times how often it occurs in identifier.""" + for identifier, occurrences in identifiers.items(): + weighted_ids[identifier] += (weight * occurrences) + return weighted_ids + + def search_size_1(self, queries: list, original_words: set, orig_word_weight=5, exact_word_weight=1) -> dict: + """Return a dict of {query_word: Counter(identifier)}. + + queries: is a list of len 1 tuple/lists of words that are a searched word or a 'spell checked' similar word + original words: a list of words actually searched for (not including spellchecked) + + orig_word_weight: additional weight to add to original words + exact_word_weight: additional weight to add to exact word matches (as opposed to be 'in' str) + + First, we find all matching words, creating a dict of words in 'queries' as keys and words matching that query word as list of values + Next, we convert this to identifiers and add weights: + Weight will be increased if matching 'orig_word_weight' or 'exact_word_weight' + """ + matches = {} + # add each word in search index if query_word in word + for word in self.word_to_identifier.keys(): + for query in queries: + # query is list/tuple of len 1 + query_word = query[0] # only use the word + if query_word in word: + words = matches.get(query_word, []) + words.extend([word]) + matches[query_word] = words + + # now convert matched words to matched identifiers + matched_identifiers = {} + for word, matching_words in matches.items(): + for matched_word in matching_words: + weight = self.base_weight + id_counter = matched_identifiers.get(word, Counter()) + + # add the word n times, where n is the weight, original search word is weighted higher than alternatives + if matched_word in original_words: + weight += orig_word_weight # increase weight for original word + if matched_word == word: + weight += exact_word_weight # increase weight for exact matching word + + id_counter = self.weigh_identifiers(self.word_to_identifier[matched_word], weight, id_counter) + matched_identifiers[word] = id_counter + + return matched_identifiers + + def fuzzy_search(self, text: str, return_counter: bool = False) -> list: + """Search the dataframe, finding approximate matches and return a list of identifiers, + ranked by how well each identifier matches the search text. + + 1. First, identifiers matching single words (and spell-checked alternatives) are found and weighted. + 2. If the search term consisted of multiple words, combinations of those words are checked next. + 2.1 Increasing in size (first two words, then three etc.), we look for identifiers that contain that set of + words, these are also weighted, based on the sum of all one-word weights (from first step) and the length + of the sequence. + 2.2 Next, we also look specifically for combinations occurring next to each other. And add more weight like + the step above (2.1). + We multiply the weighting of step 2 by the sequence length, based on the assumption that finding more search + words will be a more relevant result than just finding a single word, and again if they are in the + correct order. + + Finally, all found identifiers are sorted on their weight and returned. + """ + text = text.strip() + + queries = self.build_queries(text) + + # make list of unique original words + orig_words = OrderedDict() + for word in text.split(" "): + orig_words[word] = False + orig_words = orig_words.keys() + orig_words = {self.clean_text(word) for word in orig_words} + + # order the queries by the amount of words they contain + # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space + queries_by_size = OrderedDict() + longest_query = max([len(q) for q in queries]) + for query_len in range(1, longest_query + 1): + queries_by_size[query_len] = [q for q in queries if len(q) == query_len] + + # first handle queries of length 1 + query_to_identifier = self.search_size_1(queries_by_size[1], orig_words) + + # get all results into a df, we rank further later + all_identifiers = set() + for id_list in [id_list for id_list in query_to_identifier.values()]: + all_identifiers.update(id_list) + search_df = self.df.loc[list(all_identifiers)] + + # now, we search for combinations of query words and get only those identifiers + # we then reduce de search_df further for only those matching identifiers + # we then search the permutations of that set of words + for q_len, query_set in queries_by_size.items(): + if q_len == 1: + # we already did these above + continue + for query in query_set: + # get the intersection of all identifiers + # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query + # this ensures we only ever search data where ALL items occur to substantially reduce search-space + # finally, make this a Counter (with each item=1) so we can properly weigh things later + query_id_sets = [set(query_to_identifier.get(q_word)) for q_word in query if + query_to_identifier.get(q_word, False)] + if len(query_id_sets) == 0: + continue + query_identifier_set = set.intersection(*query_id_sets) + if len(query_identifier_set) == 0: + # there is no match for this combination of query words, skip + break + + # now we convert the query identifiers to a Counter of 'occurrence', + # where we weigh queries with only original words higher + query_identifiers = Counter() + for identifier in query_identifier_set: + weight = 0 + for query_word in query: + # if the query_word and identifier combination exist get score, otherwise 0 + weight += query_to_identifier.get(query_word, {}).get(identifier, 0) + + query_identifiers[identifier] = weight + + # we now add these identifiers to a counter for this query name, + query_name = " ".join(query) + + weight = self.base_weight * q_len + query_to_identifier[query_name] = self.weigh_identifiers(query_identifiers, weight, Counter()) + + # now search for all permutations of this query combined with a space + query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] + for query_perm in itertools.permutations(query): + mask = self.filter_dataframe(query_df, " ".join(query_perm), search_columns=["query_col"]) + new_df = query_df.loc[mask].reset_index(drop=True) + if len(new_df) == 0: + # there is no match for this permutation of words, skip + continue + new_id_list = new_df[self.identifier_name] + + new_ids = Counter() + for new_id in new_id_list: + new_ids[new_id] = query_identifiers[new_id] + + # we weigh a combination of words that is next also to each other even higher than just the words separately + query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, + query_to_identifier[query_name]) + # now finally, move to one object sorted list by highest score + all_identifiers = Counter() + for identifiers in query_to_identifier.values(): + all_identifiers += identifiers + + if return_counter: + return all_identifiers + # now sort on highest weights and make list type + sorted_identifiers = [identifier for identifier, _ in all_identifiers.most_common()] + return sorted_identifiers + + def search(self, text) -> list: + """Search the dataframe on this text, return a sorted list of identifiers.""" + t = time() + text = text.strip() + + if len(text) == 0: + logger.debug(f"Empty search, returned all items") + return self.df.index.to_list() + + fuzzy_identifiers = self.fuzzy_search(text) + if len(fuzzy_identifiers) == 0: + logger.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return [] + + # take the fuzzy search sub-set of data and search it literally + df = self.df.loc[fuzzy_identifiers].copy() + + literal_identifiers = self.literal_search(text, df) + if len(literal_identifiers) == 0: + logger.debug( + f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return fuzzy_identifiers + + # append any fuzzy identifiers that were not found in the literal search + literal_id_set = set(literal_identifiers) + remaining_fuzzy_identifiers = [ + _id for _id in fuzzy_identifiers if _id not in literal_id_set] + identifiers = literal_identifiers + remaining_fuzzy_identifiers + + logger.debug( + f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return identifiers diff --git a/activity_browser/bwutils/searchengine/metadata_search.py b/activity_browser/bwutils/searchengine/metadata_search.py new file mode 100644 index 000000000..1814a3e8a --- /dev/null +++ b/activity_browser/bwutils/searchengine/metadata_search.py @@ -0,0 +1,447 @@ +from itertools import permutations +from collections import Counter, OrderedDict +from logging import getLogger +from time import time +from typing import Optional +import pandas as pd + +from activity_browser.bwutils.searchengine import SearchEngine + + +log = getLogger(__name__) + + +class MetaDataSearchEngine(SearchEngine): + + # caching for faster operation + def database_id_manager(self, database): + if not hasattr(self, "all_database_ids"): + self.all_database_ids = {} + + if database_ids := self.all_database_ids.get(database): + self.database_ids = database_ids + self.current_database = database + elif database is not None: + self.database_ids = set(self.df[self.df["database"] == database].index.to_list()) + self.all_database_ids[database] = self.database_ids + self.current_database = database + else: + self.database_ids = None + self.current_database = "_@@NO_DB_" + return self.database_ids + + def reset_database_id_manager(self): + if hasattr(self, "all_database_ids"): + del self.all_database_ids + if hasattr(self, "database_ids"): + del self.database_ids + + def database_word_manager(self, database): + if not hasattr(self, "all_database_words"): + self.all_database_words = {} + + if database_words := self.all_database_words.get(database): + self.database_words = database_words + elif database is not None: + ids = self.database_id_manager(database) + self.database_words = self.reverse_dict_many_to_one({_id: self.identifier_to_word[_id] for _id in ids}) + self.all_database_words[database] = self.database_words + else: + self.database_words = None + return self.database_words + + def reset_database_word_manager(self, database): + if hasattr(self, "all_database_words") and self.all_database_words.get(database): + del self.all_database_words[database] + if hasattr(self, "database_words"): + del self.database_words + + def database_search_cache(self, database, query, result = None): + if not hasattr(self, "search_cache"): + self.search_cache = {} + + if result: + if self.search_cache.get(database): + self.search_cache[database][query] = result + else: + self.search_cache[database] = {query: result} + return + if db_cache := self.search_cache.get(database): + if cached_result := db_cache.get(query): + return cached_result + return + + def reset_search_cache(self, database): + if hasattr(self, "search_cache") and self.search_cache.get(database): + del self.search_cache[database] + + def reset_all_caches(self, databases): + self.reset_database_id_manager() + for database in databases: + self.reset_database_word_manager(database) + self.reset_search_cache(database) + + def add_identifier(self, data: pd.DataFrame) -> None: + super().add_identifier(data) + self.reset_all_caches(data["database"].unique()) + + def remove_identifiers(self, identifiers, logging=True) -> None: + t = time() + + identifiers = set(identifiers) + current_identifiers = set(self.df.index.to_list()) + identifiers = identifiers | current_identifiers # only remove identifiers currently in the data + databases = self.df.loc[identifiers, ["databases"]].unique() # extract databases for cache cleaning + if len(identifiers) == 0: + return + + for identifier in identifiers: + super().remove_identifier(identifier, logging=False) + + if logging: + log.debug(f"Search index updated in {time() - t:.2f} seconds " + f"for {len(identifiers)} removed items ({len(self.df)} items ({self.size_of_index()}) currently).") + self.reset_all_caches(databases) + + def change_identifier(self, identifier, data: pd.DataFrame) -> None: + super().change_identifier(identifier, data) + self.reset_all_caches(data["database"].unique()) + + def auto_complete(self, word: str, context: Optional[set] = set(), database: Optional[str] = None) -> list: + """Based on spellchecker, make more useful for autocompletions + """ + def word_to_identifier_to_word(check_word): + if len(context) == 0: + return 1 + multiplier = 1 + for identifier in self.word_to_identifier[check_word]: + for context_word in context: + for spell_checked_context_word in spell_checked_context[context_word]: + if spell_checked_context_word in self.identifier_to_word[identifier]: + multiplier += 1 + if context_word not in self.word_to_identifier.keys(): + continue + if context_word in self.identifier_to_word[identifier]: + multiplier += 4 + return multiplier + + # count occurrences of a word, count double so word_to_identifier_to_word will never multiply by 1 + count_occurrence = lambda x: sum(self.word_to_identifier[x].values()) * 2 + + if len(word) <= 1: + return [] + + self.database_id_manager(database) + + if len(context) > 0: + spell_checked_context = {} + for context_word in context: + spell_checked_context[context_word] = self.spell_check(context_word).get(context_word, [])[:5] + + matches_min = 2 # ideally we have at least this many alternatives + matches_max = 4 # ideally don't much more than this many matches + never_accept_this = 4 # values this edit distance or over always rejected + # or max 2/3 of len(word) if less than never_accept_this + never_accept_this = int(round(max(1, min((len(word) * 0.66), never_accept_this)), 0)) + + # first, find possible matches quickly + q_grams = self.text_to_positional_q_gram(word) + possible_matches = self.find_q_gram_matches(set(q_grams), return_all=True) + + first_matches = Counter() + other_matches = {} + probably_keys = Counter() # if we suspect it's a key hash, dump it at the end of the list + + # now, refine with edit distance + for row in possible_matches.itertuples(): + if word == row[1]: + continue + # find edit distance of same size strings + edit_distance = self.osa_distance(word, row[1][:len(word)], cutoff=never_accept_this) + if len(row[1]) == 32 and edit_distance <= 1: + probably_keys[row[1]] = 100 - edit_distance # keys need to be sorted on edit distance, not on occurence + elif edit_distance == 0: + first_matches[row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) + elif edit_distance < never_accept_this and len(first_matches) < matches_min: + if not other_matches.get(edit_distance): + other_matches[edit_distance] = Counter() + other_matches[edit_distance][row[1]] = count_occurrence(row[1]) * word_to_identifier_to_word(row[1]) + else: + continue + + # add matches in correct order: + matches = [match for match, _ in first_matches.most_common()] + # if we have fewer matches than goal, add more 'less good' matches + if len(matches) < matches_min: + for i in range(1, never_accept_this): + # iteratively increase matches with 'worse' results so we hit goal of minimum alternatives + if new := other_matches.get(i): + prev_num = 10e100 + for match, num in new.most_common(): + if num == prev_num: + matches.append(match) + elif num != prev_num and len(matches) <= matches_max: + matches.append(match) + else: + break + prev_num = num + + matches = matches + [match for match, _ in probably_keys.most_common()] + return matches + + def find_q_gram_matches(self, q_grams: set, return_all: bool = False) -> pd.DataFrame: + """Overwritten for extra database specific reduction of results. + """ + n_q_grams = len(q_grams) + + matches = {} + + # find words that match our q-grams + for q_gram in q_grams: + if words := self.q_gram_to_word.get(q_gram, False): + # q_gram exists in our search index + for word in words: + if isinstance(self.database_ids, set): + # DATABASE SPECIFIC now filter on whether word is in the database + in_db = False + for _id in self.word_to_identifier[word]: + if _id in self.database_ids: + in_db = True + break + else: + in_db = True + if in_db: + matches[word] = matches.get(word, 0) + words[word] + + # if we find no results, return an empty dataframe + if len(matches) == 0: + return pd.DataFrame({"word": [], "matches": []}) + + # otherwise, create a dataframe and + # reduce search results to most relevant results + matches = {"word": matches.keys(), "matches": matches.values()} + matches = pd.DataFrame(matches) + max_q = max(matches["matches"]) # this has the most matching q-grams + + # determine how many results we want to keep based on how good our results are + if not return_all: + min_q = min(max(max_q * 0.32, # have at least a third of q-grams of best match or... + max(n_q_grams * 0.5, # if more, at least half the q-grams in the query word? + 1)), # okay just do 1 q-gram if there are no more in the word + max_q) # never have min_q be over max_q + else: + min_q = 0 + + matches = matches[matches["matches"] >= min_q] + matches = matches.sort_values(by="matches", ascending=False) + matches = matches.reset_index(drop=True) + + return matches.iloc[:min(len(matches), 2500), :] # return at most this many results + + def search_size_1(self, queries: list, original_words: set, orig_word_weight=5, exact_word_weight=1) -> dict: + """Return a dict of {query_word: Counter(identifier)}. + + queries: is a list of len 1 tuple/lists of words that are a searched word or a 'spell checked' similar word + original words: a list of words actually searched for (not including spellchecked) + + orig_word_weight: additional weight to add to original words + exact_word_weight: additional weight to add to exact word matches (as opposed to be 'in' str) + + First, we find all matching words, creating a dict of words in 'queries' as keys and words matching that query word as list of values + Next, we convert this to identifiers and add weights: + Weight will be increased if matching 'orig_word_weight' or 'exact_word_weight' + """ + matches = {} + t2 = time() + # add each word in search index if query_word in word + for word in self.database_words.keys(): + for query in queries: + # query is list/tuple of len 1 + query_word = query[0] # only use the word + if query_word in word: + words = matches.get(query_word, []) + words.extend([word]) + matches[query_word] = words + + # now convert matched words to matched identifiers + matched_identifiers = {} + for word, matching_words in matches.items(): + if result := self.database_search_cache(self.current_database, word): + matched_identifiers[word] = result + continue + id_counter = matched_identifiers.get(word, Counter()) + for matched_word in matching_words: + weight = self.base_weight + + # add the word n times, where n is the weight, original search word is weighted higher than alternatives + if matched_word in original_words: + weight += orig_word_weight # increase weight for original word + if matched_word == word: + weight += exact_word_weight # increase weight for exact matching word + + id_counter = self.weigh_identifiers(self.database_words[matched_word], weight, id_counter) + matched_identifiers[word] = id_counter + self.database_search_cache(self.current_database, word, matched_identifiers[word]) + + return matched_identifiers + + def fuzzy_search(self, text: str, database: Optional[str] = None, return_counter: bool = False, logging: bool = True) -> list: + """Overwritten for extra database specific reduction of results. + """ + t = time() + text = text.strip() + + if len(text) == 0: + log.debug(f"Empty search, returned all items") + return self.df.index.to_list() + + # DATABASE SPECIFIC get the set of ids that is in this database + self.database_id_manager(database) + self.database_word_manager(database) + + queries = self.build_queries(text) + + # make list of unique original words + orig_words = OrderedDict() + for word in text.split(" "): + orig_words[word] = False + orig_words = orig_words.keys() + orig_words = {self.clean_text(word) for word in orig_words} + + # order the queries by the amount of words they contain + # we do this because longer queries (more words) are harder to find, but we have many alternatives so we search in a smaller search space + queries_by_size = OrderedDict() + longest_query = max([len(q) for q in queries]) + for query_len in range(1, longest_query + 1): + queries_by_size[query_len] = [q for q in queries if len(q) == query_len] + + # first handle queries of length 1 + query_to_identifier = self.search_size_1(queries_by_size[1], orig_words) + + # DATABASE SPECIFIC ensure all identifiers are in the database + if isinstance(self.database_ids, set): + new_q2i = {} + for word, _ids in query_to_identifier.items(): + keep = set.intersection(set(_ids.keys()), self.database_ids) + new_id_counter = Counter() + for _id in keep: + new_id_counter[_id] = _ids[_id] + if len(new_id_counter) > 0: + new_q2i[word] = new_id_counter + query_to_identifier = new_q2i + + # get all results into a df, we rank further later + all_identifiers = set() + for id_list in [id_list for id_list in query_to_identifier.values()]: + all_identifiers.update(id_list) + search_df = self.df.loc[list(all_identifiers)] + + # now, we search for combinations of query words and get only those identifiers + # we then reduce de search_df further for only those matching identifiers + # we then search the permutations of that set of words + for q_len, query_set in queries_by_size.items(): + if q_len == 1: + # we already did these above + continue + for query in query_set: + # get the intersection of all identifiers + # meaning, a set of identifiers that occur in ALL sets of len(1) for the individual words in the query + # this ensures we only ever search data where ALL items occur to substantially reduce search-space + # finally, make this a Counter (with each item=1) so we can properly weigh things later + query_id_sets = [set(query_to_identifier.get(q_word)) for q_word in query if + query_to_identifier.get(q_word, False)] + if len(query_id_sets) == 0: + continue + query_identifier_set = set.intersection(*query_id_sets) + if len(query_identifier_set) == 0: + # there is no match for this combination of query words, skip + break + + # now we convert the query identifiers to a Counter of 'occurrence', + # where we weigh queries with only original words higher + query_identifiers = Counter() + for identifier in query_identifier_set: + weight = 0 + for query_word in query: + # if the query_word and identifier combination exist get score, otherwise 0 + weight += query_to_identifier.get(query_word, {}).get(identifier, 0) + + query_identifiers[identifier] = weight + + # we now add these identifiers to a counter for this query name, + query_name = " ".join(query) + + weight = self.base_weight * q_len + query_to_identifier[query_name] = self.weigh_identifiers(query_identifiers, weight, Counter()) + + # now search for all permutations of this query combined with a space + query_df = search_df[search_df[self.identifier_name].isin(query_identifiers)] + for query_perm in permutations(query): + query_perm_str = " ".join(query_perm) + if result := self.database_search_cache(self.current_database, query_perm_str): + new_ids = result + else: + mask = self.filter_dataframe(query_df, query_perm_str, search_columns=["query_col"]) + new_df = query_df.loc[mask].reset_index(drop=True) + if len(new_df) == 0: + # there is no match for this permutation of words, skip + continue + new_id_list = new_df[self.identifier_name] + + new_ids = Counter() + for new_id in new_id_list: + new_ids[new_id] = query_identifiers[new_id] + self.database_search_cache(self.current_database, query_perm_str, new_ids) + # we weigh a combination of words that is next also to each other even higher than just the words separately + query_to_identifier[query_name] = self.weigh_identifiers(new_ids, weight, + query_to_identifier[query_name]) + # now finally, move to one object sorted list by highest score + all_identifiers = Counter() + for identifiers in query_to_identifier.values(): + all_identifiers += identifiers + + if return_counter: + return_this = all_identifiers + else: + # now sort on highest weights and make list type + return_this = [identifier[0] for identifier in all_identifiers.most_common()] + if logging: + log.debug( + f"Found {len(all_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return return_this + + def search(self, text, database: Optional[str] = None) -> list: + """Search the dataframe on this text, return a sorted list of identifiers.""" + t = time() + text = text.strip() + + if len(text) == 0: + log.debug(f"Empty search, returned all items") + return self.df.index.to_list() + + # get the set of ids that is in this database + self.database_id_manager(database) + + fuzzy_identifiers = self.fuzzy_search(text, database=database, logging=False) + if len(fuzzy_identifiers) == 0: + log.debug(f"Found 0 search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return [] + + # take the fuzzy search sub-set of data and search it literally + df = self.df.loc[fuzzy_identifiers].copy() + + literal_identifiers = self.literal_search(text, df) + if len(literal_identifiers) == 0: + log.debug( + f"Found {len(fuzzy_identifiers)} search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return fuzzy_identifiers + + # append any fuzzy identifiers that were not found in the literal search + literal_id_set = set(literal_identifiers) + remaining_fuzzy_identifiers = [ + _id for _id in fuzzy_identifiers if _id not in literal_id_set] + identifiers = literal_identifiers + remaining_fuzzy_identifiers + + log.debug( + f"Found {len(identifiers)} ({len(literal_identifiers)} literal) search results for '{text}' in {len(self.df)} items in {time() - t:.2f} seconds") + return identifiers diff --git a/activity_browser/bwutils/sensitivity_analysis.py b/activity_browser/bwutils/sensitivity_analysis.py index 1a12f8cc0..8dfbed818 100644 --- a/activity_browser/bwutils/sensitivity_analysis.py +++ b/activity_browser/bwutils/sensitivity_analysis.py @@ -8,16 +8,15 @@ import os import traceback from time import time -from logging import getLogger +from loguru import logger import bw2calc as bc import numpy as np import pandas as pd +import bw2data as bd from SALib.analyze import delta -from activity_browser.mod import bw2data as bd - -from ..settings import ab_settings +# from ..settings import ab_settings from .montecarlo import MonteCarloLCA, perform_MonteCarlo_LCA try: @@ -28,7 +27,7 @@ from bw2calc import GraphTraversal -log = getLogger(__name__) + def get_lca(fu, method): @@ -36,7 +35,7 @@ def get_lca(fu, method): lca = bc.LCA(fu, method=method) lca.lci() lca.lcia() - log.info(f"Non-stochastic LCA score: {lca.score}") + logger.info(f"Non-stochastic LCA score: {lca.score}") # add reverse dictionaries lca.activity_dict_rev, lca.product_dict_rev, lca.biosphere_dict_rev = ( @@ -57,7 +56,7 @@ def filter_technosphere_exchanges(lca, cutoff=0.05, max_calc=1000): for e in res["edges"]: if e.consumer_index != -1: # filter out head introduced in graph traversal technosphere_exchange_indices.append((e.producer_index, e.consumer_index)) - log.info( + logger.info( "TECHNOSPHERE {} filtering resulted in {} of {} exchanges and took {} iterations in {} seconds.".format( lca.technosphere_matrix.shape, len(technosphere_exchange_indices), @@ -78,7 +77,7 @@ def filter_biosphere_exchanges(lca, cutoff=0.005): finv = inv.multiply(abs(inv) > abs(lca.score / (1 / cutoff))) biosphere_exchange_indices = list(zip(*finv.nonzero())) explained_fraction = finv.sum() / lca.score - log.info( + logger.info( "BIOSPHERE {} filtering resulted in {} of {} exchanges ({}% of total impact) and took {} seconds.".format( inv.shape, finv.nnz, @@ -140,7 +139,7 @@ def drop_no_uncertainty_exchanges(excs, indices): if exc.get("uncertainty type") and exc.get("uncertainty type") >= 1: excs_no.append(exc) indices_no.append(ind) - log.info( + logger.info( "Dropping {} exchanges of {} with no uncertainty. {} remaining.".format( len(excs) - len(excs_no), len(excs), len(excs_no) ) @@ -214,7 +213,7 @@ def get_CF_dataframe(lca, only_uncertain_CFs=True): "CF: " + bio_act["name"] + str(bio_act["categories"]) ) - log.info( + logger.info( "CHARACTERIZATION FACTORS filtering resulted in including {} of {} characteriation factors.".format( len(data), len(lca.cf_params), @@ -230,10 +229,10 @@ def get_parameters_DF(mc): if bool(mc.parameter_data): # returns False if dict is empty dfp = pd.DataFrame(mc.parameter_data).T dfp["GSA name"] = "P: " + dfp["name"] - log.info(f"PARAMETERS: {len(dfp)}") + logger.info(f"PARAMETERS: {len(dfp)}") return dfp else: - log.info("PARAMETERS: None included.") + logger.info("PARAMETERS: None included.") return pd.DataFrame() # return emtpy df @@ -330,10 +329,10 @@ def perform_GSA( except Exception as e: traceback.print_exc() # todo: QMessageBox.warning(self, 'Could not perform Delta analysis', str(e)) - log.error("Initializing the GSA failed.") + logger.error("Initializing the GSA failed.") return None - log.info( + logger.info( f"-- GSA --\n Project: {bd.projects.current} CS: {self.mc.cs_name} " f"Activity: {self.activity} Method: {self.method}", ) @@ -421,12 +420,12 @@ def perform_GSA( # self.Y = np.log(np.abs(self.Y)) # this makes it more robust for very uneven distributions of LCA results if np.all(self.Y > 0): # all positive numbers self.Y = np.log(np.abs(self.Y)) - log.info("All positive LCA scores. Log-transformation performed.") + logger.info("All positive LCA scores. Log-transformation performed.") elif np.all(self.Y < 0): # all negative numbers self.Y = -np.log(np.abs(self.Y)) - log.info("All negative LCA scores. Log-transformation performed.") + logger.info("All negative LCA scores. Log-transformation performed.") else: # mixed positive and negative numbers - log.warning( + logger.warning( "Log-transformation cannot be applied as LCA scores overlap zero." ) @@ -440,7 +439,7 @@ def perform_GSA( # perform delta analysis time_delta = time() self.Si = delta.analyze(self.problem, self.X, self.Y, print_to_console=False) - log.info( + logger.info( "Delta analysis took {} seconds".format( np.round(time() - time_delta, 2), ) @@ -457,7 +456,7 @@ def perform_GSA( self.df_final.reset_index(inplace=True) self.df_final["pedigree"] = [str(x) for x in self.df_final["pedigree"]] - log.info("GSA took {} seconds".format(np.round(time() - start, 2))) + logger.info("GSA took {} seconds".format(np.round(time() - start, 2))) def get_save_name(self): save_name = ( @@ -474,11 +473,15 @@ def get_save_name(self): return save_name def export_GSA_output(self): + from ..settings import ab_settings + save_name = "gsa_output_" + self.get_save_name() self.df_final.to_excel(os.path.join(ab_settings.data_dir, save_name)) def export_GSA_input(self): """Export the input data to the GSA with a human readible index""" + from ..settings import ab_settings + X_with_index = pd.DataFrame(self.X.T, index=self.metadata.index) save_name = "gsa_input_" + self.get_save_name() X_with_index.to_excel(os.path.join(ab_settings.data_dir, save_name)) diff --git a/activity_browser/bwutils/settings.py b/activity_browser/bwutils/settings.py new file mode 100644 index 000000000..43f54a161 --- /dev/null +++ b/activity_browser/bwutils/settings.py @@ -0,0 +1,110 @@ +import copy +import json +import bw2data as bd +import bw2data.signals as bw_signals +import blinker + +from activity_browser.bwutils.filesystem import get_project_ab_path, get_appdata_path + +defaults = { + "startup": { + "brightway_directory": str(bd.projects._base_data_dir), + "saved_brightway_directories": [str(bd.projects._base_data_dir)], + "startup_project": "default", + "shown_panes": ["Databases", "Impact Categories", "Calculation Setups"], + "shown_pages": ["Welcome", "Parameters", "Settings"], + }, + "appearance": { + "theme": "default", + "pane_tab_position": "bottom", + }, + "metadatastore": { + "caching_enabled": True, + "searcher_enabled": True, + }, + "plugins": { + "enabled_plugins": [], + } +} + + +class Settings: + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + self._initialized = True + + self.global_config = {} + self.virtual_config = {} + self.project_config = {} + + self.load_global_settings() + self.load_virtual_settings() + self.load_project_settings() + + self.changed = blinker.Signal() + + bw_signals.project_changed.connect(self.load_project_settings) + + def __getitem__(self, key): + if key in self.virtual_config: + return self.virtual_config[key] + if key in self.project_config: + return self.project_config[key] + if key in self.global_config: + return self.global_config[key] + if key in defaults: + return defaults[key] + raise KeyError(f"Setting '{key}' not found in any configuration level.") + + def __setitem__(self, key, value): + if isinstance(key, tuple): + key, subkey = key + else: + subkey = "global" + + if subkey == "global": + self.global_config[key] = value + elif subkey == "project": + self.project_config[key] = value + else: + raise KeyError("Subkey must be 'global' or 'project'") + + def save(self): + global_path = get_appdata_path() / "settings.json" + json.dump(self.global_config, open(global_path, "w"), indent=4) + + project_path = get_project_ab_path() / "settings.json" + json.dump(self.project_config, open(project_path, "w"), indent=4) + + self.changed.send() + + def load_global_settings(self): + global_path = get_appdata_path() / "settings.json" + self.global_config = json.load(open(global_path)) if global_path.exists() else copy.deepcopy(defaults) + + def load_project_settings(self, *args, **kwargs): + project_path = get_project_ab_path() / "settings.json" + self.project_config = json.load(open(project_path)) if project_path.exists() else {} + + def load_virtual_settings(self): + pass # Implementation later based on environment variables + + def restore_defaults(self): + self.global_config = copy.deepcopy(defaults) + global_path = get_appdata_path() / "settings.json" + json.dump(self.global_config, open(global_path, "w"), indent=4) + + self.project_config = {} + project_path = get_project_ab_path() / "settings.json" + project_path.unlink(missing_ok=True) + + diff --git a/activity_browser/bwutils/strategies.py b/activity_browser/bwutils/strategies.py index 183402a2b..f23967fb7 100644 --- a/activity_browser/bwutils/strategies.py +++ b/activity_browser/bwutils/strategies.py @@ -2,7 +2,7 @@ import hashlib import json from typing import Collection -from logging import getLogger +from loguru import logger from bw2io.errors import StrategyError from bw2io.strategies.generic import (format_nonunique_key_error, @@ -15,7 +15,7 @@ from ..bwutils.errors import ExchangeErrorValues from .commontasks import clean_activity_name -log = getLogger(__name__) + TECHNOSPHERE_TYPES = {"technosphere", "substitution", "production"} BIOSPHERE_TYPES = {"economic", "emission", "natural resource", "social"} @@ -29,6 +29,26 @@ "location", ) +def metadatastore_link(data: list) -> list: + from .metadata import MetaDataStore + mds = MetaDataStore() + + for act in data: + for exc in act.get("exchanges", []): + match = mds.match( + name=exc.get("name"), + database=exc.get("database"), + categories=exc.get("categories"), + unit=exc.get("unit"), + product=exc.get("reference product"), + location=exc.get("location"), + ) + if len(match) == 1: + exc["input"] = match.index[0] + + return data + + def relink_exchanges_dbs(data: Collection, relink: dict) -> Collection: """Use this to relink exchanges during an actual import.""" @@ -152,7 +172,7 @@ def relink_exchanges(exchanges: list, candidates: dict, duplicates: dict) -> tup # Commit changes every 10k exchanges. transaction.commit() except (StrategyError, bd.errors.ValidityError) as e: - log.error(e) + logger.error(e) transaction.rollback() return (remainder, altered, unlinked_exchanges) @@ -165,7 +185,7 @@ def relink_exchanges_existing_db( This means possibly doing a lot of sqlite update calls. """ if old == other.name: - log.info("No point relinking to same database.") + logger.info("No point relinking to same database.") return assert db.backend == "sqlite", "Relinking only allowed for SQLITE backends" assert other.backend == "sqlite", "Relinking only allowed for SQLITE backends" @@ -195,7 +215,7 @@ def relink_exchanges_existing_db( exchanges, candidates, duplicates ) db.process() - log.info( + logger.info( "Relinked database '{}', {} exchange inputs changed from '{}' to '{}'.".format( db.name, altered, old, other.name ) @@ -205,7 +225,7 @@ def relink_exchanges_existing_db( def relink_activity_exchanges(act, old: str, other: bd.Database) -> tuple: if old == other.name: - log.info("No point relinking to same database.") + logger.info("No point relinking to same database.") return db = bd.Database(act.key[0]) assert db.backend == "sqlite", "Relinking only allowed for SQLITE backends" @@ -232,7 +252,7 @@ def relink_activity_exchanges(act, old: str, other: bd.Database) -> tuple: exchanges, candidates, duplicates ) db.process() - log.info( + logger.info( "Relinked database '{}', {} exchange inputs changed from '{}' to '{}'.".format( db.name, altered, old, other.name ) @@ -253,14 +273,25 @@ def alter_database_name(data: list, old: str, new: str) -> list: # Note: this will only alter database if the field exists in the exchange. if exc.get("database") == old: exc["database"] = new - for p, d in ds.get("parameters", {}).items(): + for p in ds.get("parameters", []): # Any parameters found here are activity parameters and we can # overwrite the database without issue. - d["database"] = new + p["database"] = new if ds.get("processor", (None, None))[0] == old: ds["processor"] = (new, ds["processor"][1]) return data +def alter_exchange_database_name(data: list, linking_dict: dict[str, str]) -> list: + """For ABExcelImporter, go through data and replace all instances + of the `old` database name with `new` in exchanges only. + """ + for ds in data: + for exc in ds.get("exchanges", []): + # Note: this will only alter database if the field exists in the exchange. + if exc.get("database") in linking_dict: + exc["database"] = linking_dict[exc["database"]] + return data + def hash_parameter_group(data: list) -> list: """For ABExcelImporter, go through `data` and change all the activity parameter diff --git a/activity_browser/bwutils/superstructure/dataframe.py b/activity_browser/bwutils/superstructure/dataframe.py index 8ecf63d6e..d61a50979 100644 --- a/activity_browser/bwutils/superstructure/dataframe.py +++ b/activity_browser/bwutils/superstructure/dataframe.py @@ -10,13 +10,15 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication, QPushButton +from activity_browser.bwutils.metadata import MetaDataStore from ..errors import ScenarioDatabaseNotFoundError -from ..metadata import AB_metadata from ..utils import Index from .activities import data_from_index from .file_dialogs import ABPopup from .utils import SUPERSTRUCTURE +metadata = MetaDataStore() + def superstructure_from_arrays( samples: np.ndarray, indices: np.ndarray, names: List[str] = None @@ -45,7 +47,7 @@ def superstructure_from_arrays( def superstructure_from_scenario_exchanges(scenarios: dict[str, dict[int, float]]): - from activity_browser.bwutils import exchanges_to_sdf + from activity_browser.bwutils.commontasks import exchanges_to_sdf from bw2data import Edge scenarios = transpose_scenarios_to_exchange_ids(scenarios) @@ -61,7 +63,7 @@ def superstructure_from_scenario_exchanges(scenarios: dict[str, dict[int, float] def regular_exchange_to_sdf(exchange_id: int, scenarios: dict[str, float]): - from activity_browser.bwutils import exchanges_to_sdf + from activity_browser.bwutils.commontasks import exchanges_to_sdf exc = bd.Edge(bd.Edge.ORMDataset.get_by_id(exchange_id)).as_dict() df = exchanges_to_sdf([exc]) @@ -73,7 +75,7 @@ def regular_exchange_to_sdf(exchange_id: int, scenarios: dict[str, float]): def mf_exchange_to_sdf(exchange_id: int, scenarios: dict[str, float]): - from activity_browser.bwutils import exchanges_to_sdf + from activity_browser.bwutils.commontasks import exchanges_to_sdf exc = bf.MFExchange(bf.MFExchange.ORMDataset.get_by_id(exchange_id)) @@ -123,7 +125,7 @@ def arrays_from_indexed_superstructure( ) -> Tuple[np.ndarray, np.ndarray]: result = np.zeros(df.shape[0], dtype=object) - meta = AB_metadata.dataframe["id"] + meta = metadata.dataframe["id"] meta.index = meta.index.to_flat_index() id_df = pd.merge(df, meta, left_on="input", right_index=True).rename(columns={"id":"input_id"}) @@ -285,8 +287,8 @@ def exchange_replace_database( changes = ["from database", "from key", "to database", "to key"] # Load all required databases into the metadata - AB_metadata.add_metadata(replacements.values()) - metadata = AB_metadata.dataframe + metadata.add_metadata(replacements.values()) + meta = metadata.dataframe for idx in df.index: df.loc[idx, changes] = exchange_replace_database( diff --git a/activity_browser/bwutils/superstructure/excel.py b/activity_browser/bwutils/superstructure/excel.py index d2cbb415e..2701b7c61 100644 --- a/activity_browser/bwutils/superstructure/excel.py +++ b/activity_browser/bwutils/superstructure/excel.py @@ -2,14 +2,14 @@ from ast import literal_eval from pathlib import Path from typing import List, Union -from logging import getLogger +from loguru import logger import openpyxl import pandas as pd from .utils import SUPERSTRUCTURE -log = getLogger(__name__) + def convert_tuple_str(x): @@ -24,7 +24,7 @@ def get_sheet_names(document_path: Union[str, Path]) -> List[str]: wb = openpyxl.load_workbook(filename=document_path, read_only=True) return wb.sheetnames except UnicodeDecodeError as e: - log.error("Given document uses an unknown encoding: {}".format(e)) + logger.error("Given document uses an unknown encoding: {}".format(e)) def get_header_index(document_path: Union[str, Path], import_sheet: int): @@ -45,7 +45,7 @@ def get_header_index(document_path: Union[str, Path], import_sheet: int): e.__traceback__ ) except UnicodeDecodeError as e: - log.error("Given document uses an unknown encoding: {}".format(e)) + logger.error("Given document uses an unknown encoding: {}".format(e)) wb.close() raise ValueError("Could not find required headers in given document sheet.") diff --git a/activity_browser/bwutils/superstructure/file_dialogs.py b/activity_browser/bwutils/superstructure/file_dialogs.py index 34bbf6b54..1ba5906dd 100644 --- a/activity_browser/bwutils/superstructure/file_dialogs.py +++ b/activity_browser/bwutils/superstructure/file_dialogs.py @@ -1,7 +1,6 @@ import pandas as pd from qtpy import QtCore, QtWidgets -from ...ui.icons import qicons """ The basic premise of this module is to contain a series of different popup menus that will allow the user @@ -227,6 +226,8 @@ def abQuestion(title, message, button1, button2): An ABPopup instance that provides the basic format and dialog for the popup window. Further manipulation of the object and execution (via .exec_()) is performed upon instantiation """ + from ...ui.icons import qicons + obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() obj.setWindowTitle(title) @@ -270,6 +271,8 @@ def abWarning(title, message, button1, button2=None, default=1): An ABPopup instance that provides the basic format and dialog for the popup window to provide a warning. Further manipulation of the object and execution (via .exec_()) is performed upon instantiation """ + from ...ui.icons import qicons + obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() obj.setWindowTitle(title) @@ -320,6 +323,8 @@ def abCritical(title, message, button1, button2=None, default=1): An ABPopup instance that provides the basic format and dialog for the popup window to provide a warning. Further manipulation of the object and execution (via .exec_()) is performed upon instantiation """ + from ...ui.icons import qicons + obj = ABPopup() obj.layout = QtWidgets.QVBoxLayout() obj.setWindowTitle(title) diff --git a/activity_browser/bwutils/superstructure/file_imports.py b/activity_browser/bwutils/superstructure/file_imports.py index ea4ec3fae..5185cf7cc 100644 --- a/activity_browser/bwutils/superstructure/file_imports.py +++ b/activity_browser/bwutils/superstructure/file_imports.py @@ -2,13 +2,13 @@ from abc import ABC, abstractmethod from pathlib import Path from typing import Optional, Union -from logging import getLogger +from loguru import logger import pandas as pd from ..errors import * -log = getLogger(__name__) + class ABFileImporter(ABC): @@ -75,7 +75,7 @@ def database_and_key_check(data: pd.DataFrame) -> None: ) raise IncompatibleDatabaseNamingError() except IncompatibleDatabaseNamingError as e: - log.error(msg) + logger.error(msg) raise e @staticmethod @@ -103,7 +103,7 @@ def production_process_check(data: pd.DataFrame, scenario_names: list) -> None: ) raise ActivityProductionValueError() except ActivityProductionValueError as e: - log.error(msg) + logger.error(msg) raise e @staticmethod @@ -126,7 +126,7 @@ def na_value_check(data: pd.DataFrame, fields: list) -> None: ) raise InvalidSDFEntryValue() except InvalidSDFEntryValue as e: - log.error(msg) + logger.error(msg) raise e @staticmethod diff --git a/activity_browser/bwutils/superstructure/manager.py b/activity_browser/bwutils/superstructure/manager.py index 83167c589..fa007fb49 100644 --- a/activity_browser/bwutils/superstructure/manager.py +++ b/activity_browser/bwutils/superstructure/manager.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import itertools from typing import List, Optional, Union -from logging import getLogger +from loguru import logger import numpy as np import pandas as pd @@ -21,7 +21,7 @@ from .file_dialogs import ABPopup from .utils import SUPERSTRUCTURE, _time_it_, guess_flow_type -log = getLogger(__name__) + EXCHANGE_KEYS = pd.Index(["from key", "to key"]) INDEX_KEYS = pd.Index(["from key", "to key", "flow type"]) @@ -119,7 +119,7 @@ def _combine_columns_intersect(self) -> pd.Index: absent.update(cols.symmetric_difference(scenario_columns(df))) cols = cols.intersection(scenario_columns(df)) for name in absent: - log.warning( + logger.warning( "The following scenario is not found in all provided files and is being dropped: {}".format( name ) @@ -346,7 +346,7 @@ def remove_duplicates(df: pd.DataFrame) -> pd.DataFrame: """ duplicates = df.index.duplicated(keep="last") if duplicates.any(): - log.warning( + logger.warning( "Found and dropped {} duplicate exchanges.".format(duplicates.sum()) ) return df.loc[~duplicates, :] @@ -362,7 +362,7 @@ def build_index(df: pd.DataFrame) -> pd.MultiIndex: """ unknown_flows = df.loc[:, "flow type"].isna() if unknown_flows.any(): - log.warning( + logger.warning( "Not all flow types are known, guessing {} flows".format( unknown_flows.sum() ) @@ -499,7 +499,7 @@ def check_scenario_exchange_values(df: pd.DataFrame, cols: pd.Index): critical.exec_() raise ScenarioExchangeDataNotFoundError elif nas.any(axis=0).any(): - log.warning( + logger.warning( "Replacing empty values from the last loaded scenario difference file" ) if not is_numeric_dtype(np.array(_df.loc[:, cols])): diff --git a/activity_browser/bwutils/superstructure/mlca.py b/activity_browser/bwutils/superstructure/mlca.py index c5e5e8810..89396263a 100644 --- a/activity_browser/bwutils/superstructure/mlca.py +++ b/activity_browser/bwutils/superstructure/mlca.py @@ -3,15 +3,16 @@ import numpy as np import pandas as pd +import bw2data as bd from qtpy.QtWidgets import QPushButton from activity_browser.mod import bw2data as bd -from activity_browser.bwutils import AB_metadata from ..commontasks import format_activity_label from ..errors import ScenarioExchangeNotFoundError from ..multilca import MLCA, Contributions from ..utils import Index +from ..metadata import MetaDataStore from .dataframe import (arrays_from_indexed_superstructure, filter_databases_indexed_superstructure, scenario_names_from_df) @@ -22,6 +23,7 @@ except ModuleNotFoundError: pass # removed in bw25 +metadata = MetaDataStore() class SuperstructureMLCA(MLCA): """Subclass of the `MLCA` class which adds another dimension in the form diff --git a/activity_browser/bwutils/superstructure/utils.py b/activity_browser/bwutils/superstructure/utils.py index 76a3e00b1..f5a7c5cc8 100644 --- a/activity_browser/bwutils/superstructure/utils.py +++ b/activity_browser/bwutils/superstructure/utils.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- import time -from logging import getLogger +from loguru import logger import pandas as pd from activity_browser.mod import bw2data as bd -log = getLogger(__name__) + # Different kinds of indexes, to allow for quick selection of data from # the Superstructure DataFrame. @@ -79,7 +79,7 @@ def _time_it_(func): def wrapper(*args): now = time.time() result = func(*args) - log.info(f"{func} -- {time.time() - now}") + logger.info(f"{func} -- {time.time() - now}") return result return wrapper diff --git a/activity_browser/bwutils/utils.py b/activity_browser/bwutils/utils.py index 95f972893..3b082a81f 100644 --- a/activity_browser/bwutils/utils.py +++ b/activity_browser/bwutils/utils.py @@ -4,7 +4,6 @@ import numpy as np import peewee as pw -from stats_arrays import UncertaintyBase import bw2data as bd from bw2data.backends import ActivityDataset, ExchangeDataset @@ -34,6 +33,19 @@ def deletable(self): except pw.DoesNotExist: return False + @property + def uncertainty(self): + uncertainty_keys = { + "uncertainty type", + "loc", + "scale", + "shape", + "minimum", + "maximum", + "negative", + } + return {k: v for k, v in self.data.items() if k in uncertainty_keys} + def as_gsa_tuple(self) -> tuple: """Return the parameter data formatted as follows: - Parameter name diff --git a/activity_browser/info.py b/activity_browser/info.py index 03af4642c..fcf3ba875 100644 --- a/activity_browser/info.py +++ b/activity_browser/info.py @@ -1,11 +1,4 @@ -import ast -import os.path from importlib.metadata import PackageNotFoundError, version -from logging import getLogger - -from .utils import safe_link_fetch, sort_semantic_versions - -log = getLogger(__name__) # get AB version try: @@ -13,55 +6,5 @@ except PackageNotFoundError: __version__ = "0.0.0" - -def get_compatible_versions() -> list: - """Get compatible versions of ecoinvent for this AB version. - - Reads this file on github repo: activity-browser/better_biosphere_handling/compatible_ei_versions.txt'. - Converts file content to available ecoinvent versions for each version of AB. - Finds the correct available versions for this AB version, if failing to read version, - the lowest version in the file is chosen. - """ - try: - # read versions - versions_URL = "https://raw.githubusercontent.com/LCA-ActivityBrowser/activity-browser/main/activity_browser/bwutils/ecoinvent_biosphere_versions/compatible_ei_versions.txt" - page, error = safe_link_fetch(versions_URL) - if not error: - file = page.text - else: - # silently try a local fallback: - log.debug( - f"Reading online compatible ecoinvent versions failed " - f"-attempting local fallback- with this error: {error}" - ) - file_path = os.path.join( - os.path.dirname(__file__), - "bwutils", - "ecoinvent_biosphere_versions", - "compatible_ei_versions.txt", - ) - with open(file_path, "r") as f: - file = f.read() - all_versions = ast.literal_eval(file) - - # select either the latest lower version available or if none available the lowest version for safety - sorted_versions = sort_semantic_versions(all_versions.keys()) - for ab_version in sorted_versions: - if sort_semantic_versions([__version__, ab_version])[0] == __version__: - # current version is higher than or equal to tested AB version: - ei_versions = all_versions[ab_version] - break - else: - ei_versions = all_versions[sorted_versions[-1]] - - log.debug( - f"Following versions of ecoinvent are compatible with AB {__version__}: {ei_versions}" - ) - return ei_versions - - except Exception as error: - log.debug(f"Reading local fallback failed with: {error}") - return ["3.4", "3.5", "3.6", "3.7", "3.7.1", "3.8", "3.9", "3.9.1"] - - -__ei_versions__ = get_compatible_versions() +# supported EI versions +__ei_versions__ = ["3.4", "3.5", "3.6", "3.7", "3.7.1", "3.8", "3.9", "3.9.1"] diff --git a/activity_browser/mod/README.md b/activity_browser/mod/README.md new file mode 100644 index 000000000..a1d206dc2 --- /dev/null +++ b/activity_browser/mod/README.md @@ -0,0 +1,58 @@ +# mod + +Monkey-patches and modifications to third-party libraries used by Activity Browser. + +## Overview + +This module contains patches and modifications to external libraries to fix bugs, add features, or adapt functionality for Activity Browser's specific needs. These modifications are applied at import time. + +## Directory Structure + +- **`bw2analyzer/`** - Patches for brightway2-analyzer +- **`bw2io/`** - Patches for brightway2-io +- **`ecoinvent_interface/`** - Patches for ecoinvent-interface +- **`peewee/`** - Patches for peewee ORM +- **`pyprind/`** - Patches for pyprind progress bars +- **`tqdm/`** - Patches for tqdm progress bars + +## Key Files + +- **`__init__.py`** - Imports all patched modules, replacing the original imports +- **`patching.py`** - Core patching utilities and helpers + +## How It Works + +When Activity Browser imports this module, it automatically imports the patched versions of external libraries. These patches are typically applied to: + +1. **Fix bugs** that haven't been addressed upstream +2. **Add Qt integration** for progress bars and UI elements +3. **Adapt functionality** to work better within a GUI context +4. **Add features** needed by Activity Browser but not available in the base libraries + +## Import Pattern + +The module is imported early in Activity Browser's initialization: + +```python +import activity_browser.mod.bw2analyzer as bw2analyzer +import activity_browser.mod.bw2io as bw2io +``` + +This ensures that the patched versions are used throughout the application. + +## Development Notes + +- Patches should be minimally invasive +- Document why each patch is needed +- Consider contributing fixes upstream when appropriate +- Test patches thoroughly as they modify external library behavior +- Keep patches up-to-date with upstream library versions + +## Warning + +Modifying third-party libraries can lead to maintenance challenges. Use this approach sparingly and only when: +- The issue can't be solved in Activity Browser code +- Upstream changes are not accepted or released +- The modification is essential for Activity Browser functionality + +Always prefer upstream contributions over local patches when possible. diff --git a/activity_browser/mod/bw2io/__init__.py b/activity_browser/mod/bw2io/__init__.py index 236e1b081..8f269f5f2 100644 --- a/activity_browser/mod/bw2io/__init__.py +++ b/activity_browser/mod/bw2io/__init__.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from bw2io import * @@ -7,33 +7,35 @@ -log = getLogger(__name__) + def ab_bw2setup(version): + + raise Exception("This function is deprecated.") + import bw2io as bi from activity_browser.mod.bw2io.importers.ecospold2_biosphere import ABEcospold2BiosphereImporter from activity_browser.info import __ei_versions__ - from activity_browser.utils import sort_semantic_versions from .migrations import ab_create_core_migrations ab_create_core_migrations() version = version[:3] - if version == sort_semantic_versions(__ei_versions__)[0][:3]: - log.info(f"Installing biosphere version >{version}<") + if version == __ei_versions__[0][:3]: + logger.info(f"Installing biosphere version >{version}<") # most recent version bio_import = ABEcospold2BiosphereImporter() else: - log.info(f"Installing legacy biosphere version >{version}<") + logger.info(f"Installing legacy biosphere version >{version}<") # not most recent version, import legacy biosphere from AB bio_import = ABEcospold2BiosphereImporter(version=version) bio_import.apply_strategies() - log.info("Writing biosphere database") + logger.info("Writing biosphere database") bio_import.write_database() - log.info("Writing LCIA methods") + logger.info("Writing LCIA methods") create_default_lcia_methods() # patching biosphere @@ -51,7 +53,7 @@ def ab_bw2setup(version): ] for patch in patches: - log.info(f"Applying biosphere patch: {patch}") + logger.info(f"Applying biosphere patch: {patch}") update_bio = getattr(bi.data, patch) update_bio() diff --git a/activity_browser/mod/bw2io/ecoinvent.py b/activity_browser/mod/bw2io/ecoinvent.py index adb5e6563..9f950ca3d 100644 --- a/activity_browser/mod/bw2io/ecoinvent.py +++ b/activity_browser/mod/bw2io/ecoinvent.py @@ -1,4 +1,4 @@ -from logging import getLogger +from loguru import logger from bw2io.ecoinvent import * @@ -7,7 +7,7 @@ from activity_browser.mod.ecoinvent_interface.release import ABEcoinventRelease from activity_browser.mod.bw2io.importers.ecospold2_biosphere import ABEcospold2BiosphereImporter -log = getLogger(__name__) + def ab_import_ecoinvent_release(version, system_model): @@ -32,27 +32,27 @@ def ab_import_ecoinvent_release(version, system_model): name="biosphere3", filepath=lci_path / "MasterData" / "ElementaryExchanges.xml", ) - log.info("Applying strategies") + logger.info("Applying strategies") bio_import.apply_strategies() - log.info("Writing biosphere database") + logger.info("Writing biosphere database") bio_import.write_database() bd.preferences["biosphere_database"] = "biosphere3" # importing ecoinvent through a ecospold2 importer that implements a progress_slot - log.info("Importing ecoinvent") + logger.info("Importing ecoinvent") db_name = f"ecoinvent-{version}-{system_model}" ei_import = SingleOutputEcospold2Importer( dirpath=str(lci_path / "datasets"), db_name=db_name, biosphere_database_name="biosphere3", ) - log.info("Applying strategies") + logger.info("Applying strategies") ei_import.apply_strategies() - log.info("Writing ecoinvent database") + logger.info("Writing ecoinvent database") ei_import.write_database() # importing all LCIA methods - log.info("Gathering LCIA methods") + logger.info("Gathering LCIA methods") lcia_file = ei.get_excel_lcia_file_for_version(release=release, version=version) sheet_names = get_excel_sheet_names(lcia_file) @@ -69,11 +69,11 @@ def ab_import_ecoinvent_release(version, system_model): raise ValueError( f"Can't find worksheet for characterization factors; expected `CFs`, found {sheet_names}" ) - log.info("Extracting LCIA methods") + logger.info("Extracting LCIA methods") data = dict(ExcelExtractor.extract(lcia_file)) units = header_dict(data[units_sheetname]) - log.info("Mapping LCIA methods") + logger.info("Mapping LCIA methods") cfs = header_dict(data["CFs"]) CF_COLUMN_LABELS = { diff --git a/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py b/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py index f8d9fb065..b9f644479 100644 --- a/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py +++ b/activity_browser/mod/bw2io/importers/ecospold2_biosphere.py @@ -2,11 +2,24 @@ from bw2io.importers.ecospold2_biosphere import * import pyprind -import logging +from loguru import logger import os from activity_browser.info import __ei_versions__ -from activity_browser.utils import sort_semantic_versions + + +def sort_semantic_versions(versions, highest_to_lowest: bool = True) -> list: + """Return a sorted (default highest to lowest) list of semantic versions. + + Sorts based on the semantic versioning system. + """ + return list( + sorted( + versions, + key=lambda x: tuple(map(int, x.split("."))), + reverse=highest_to_lowest, + ) + ) class ABEcospold2BiosphereImporter(Ecospold2BiosphereImporter): @@ -58,7 +71,7 @@ def extract_flow_data(o): lci_dirpath = os.path.join(os.path.dirname(mod.__file__), "ecoinvent_biosphere_versions", "legacy_biosphere") # find the most recent legacy biosphere that is equal to or older than chosen version - for ei_version in sort_semantic_versions(__ei_versions__): + for ei_version in __ei_versions__: use_version = ei_version zip_fp = os.path.join( lci_dirpath, f"ecoinvent elementary flows {use_version}.xml.zip" @@ -111,5 +124,5 @@ def apply_strategies(self, strategies=None, verbose=True): self.apply_strategy(func, verbose) def write_database(self, *args, **kwargs): - logging.getLogger(__name__).info("Writing Biosphere database") + logger.info("Writing Biosphere database") super().write_database(*args, **kwargs) diff --git a/activity_browser/static/README.md b/activity_browser/static/README.md new file mode 100644 index 000000000..630b86ffd --- /dev/null +++ b/activity_browser/static/README.md @@ -0,0 +1,63 @@ +# static + +Static resources for the Activity Browser application. + +## Overview + +This directory contains all static assets used by Activity Browser including HTML templates, stylesheets, icons, fonts, JavaScript libraries, and other non-code resources. + +## Directory Structure + +- **`css/`** - Cascading Style Sheets for HTML views +- **`database_classifications/`** - Database classification mappings and schemas +- **`fonts/`** - Font files used in the application +- **`icons/`** - Application icons in various formats and sizes +- **`javascript/`** - JavaScript libraries and scripts for web views +- **`startscreen/`** - Start screen assets and templates + +## HTML Templates + +- **`activity_graph.html`** - Template for activity relationship graph visualization +- **`navigator.html`** - Base navigator template +- **`sankey_navigator.html`** - Sankey diagram visualization template +- **`spinner.html`** - Loading spinner template +- **`tree_navigator.html`** - Tree structure navigator template + +## Purpose + +These static resources support: + +1. **Visualization** - Interactive graphs, Sankey diagrams, and charts +2. **Branding** - Application icons and logo +3. **Styling** - Consistent look and feel across web views +4. **Classification** - Database and activity classification systems +5. **User Experience** - Welcome screens, loading indicators, navigation + +## Web Views + +Activity Browser embeds web views (Qt WebEngine) for rich interactive visualizations. These HTML templates use JavaScript libraries to render: + +- Force-directed graphs showing activity relationships +- Sankey diagrams for flow visualization +- Tree navigators for hierarchical data exploration +- Interactive charts and plots + +## Resource Loading + +Static resources are accessed via: + +```python +from pathlib import Path + +static_dir = Path(__file__).parent.resolve() / "static" +icon_path = static_dir / "icons" / "main_icon.png" +``` + +## Maintenance + +When adding new static resources: +- Place files in the appropriate subdirectory +- Ensure proper licensing for third-party assets +- Optimize file sizes (compress images, minify CSS/JS) +- Document dependencies and versions for JavaScript libraries +- Include resources in `MANIFEST.in` for packaging diff --git a/activity_browser/static/css/README.md b/activity_browser/static/css/README.md new file mode 100644 index 000000000..b44887c96 --- /dev/null +++ b/activity_browser/static/css/README.md @@ -0,0 +1,245 @@ +# css + +Cascading Style Sheets for Activity Browser's HTML views. + +## Overview + +This directory contains CSS files that style the HTML-based visualizations and web views in Activity Browser. These stylesheets control the appearance of graphs, Sankey diagrams, tree navigators, and other interactive visualizations. + +## Files + +- **`navigator.common.css`** - Common styles shared across navigators +- **`navigator.css`** - Base navigator styles +- **`activity_graph.css`** - Activity relationship graph styles +- **`sankey_navigator.css`** - Sankey diagram visualization styles +- **`tree_navigator.css`** - Tree structure navigator styles + +## Purpose + +These stylesheets provide: +- **Consistent appearance** - Unified look across all visualizations +- **Responsive design** - Adapt to different window sizes +- **Interactive styling** - Hover effects, selections, highlights +- **Theme support** - Match Activity Browser's overall design +- **Accessibility** - Readable colors, proper contrast + +## Common Patterns + +### Node Styling +```css +.node { + fill: #4a90e2; + stroke: #2c5aa0; + stroke-width: 2px; + cursor: pointer; +} + +.node:hover { + fill: #5da5ff; + stroke-width: 3px; +} + +.node.selected { + stroke: #ff6b6b; + stroke-width: 4px; +} +``` + +### Edge/Link Styling +```css +.link { + stroke: #999; + stroke-opacity: 0.6; + fill: none; +} + +.link:hover { + stroke-opacity: 1; + stroke-width: 3px; +} +``` + +### Text Styling +```css +.label { + font-family: Arial, sans-serif; + font-size: 12px; + fill: #333; + pointer-events: none; +} +``` + +## navigator.common.css + +Shared styles for all navigators: +- Layout and positioning +- Controls and buttons +- Tooltips +- Loading indicators +- Error messages + +## activity_graph.css + +Styles for activity relationship graphs: +- Node appearance (activities) +- Edge appearance (exchanges/relationships) +- Labels and annotations +- Graph controls (zoom, pan) +- Legend styling + +## sankey_navigator.css + +Styles for Sankey diagrams: +- Flow paths (width proportional to amount) +- Node boxes +- Flow colors (by category) +- Tooltips showing values +- Legend and scale + +## tree_navigator.css + +Styles for tree structures: +- Tree nodes (collapsible) +- Branches/connections +- Expand/collapse icons +- Indentation levels +- Selection highlighting + +## Color Schemes + +### Default Colors +- **Primary**: Blue (#4a90e2) +- **Secondary**: Green (#2ecc71) +- **Warning**: Orange (#f39c12) +- **Error**: Red (#e74c3c) +- **Neutral**: Gray (#95a5a6) + +### Category Colors +Different colors for flow types: +- **Technosphere**: Blue +- **Biosphere**: Green +- **Production**: Orange +- **Substitution**: Purple + +## Responsive Design + +Stylesheets adapt to window size: + +```css +@media (max-width: 768px) { + .node { + /* Smaller nodes on small screens */ + r: 4px; + } + + .label { + /* Smaller text on small screens */ + font-size: 10px; + } +} +``` + +## Interactive States + +### Hover States +Visual feedback when hovering: +```css +.interactive:hover { + opacity: 0.8; + cursor: pointer; +} +``` + +### Selection States +Highlight selected items: +```css +.selected { + stroke: #ff6b6b; + stroke-width: 3px; + filter: drop-shadow(0 0 5px rgba(255, 107, 107, 0.5)); +} +``` + +### Disabled States +Gray out disabled elements: +```css +.disabled { + opacity: 0.4; + cursor: not-allowed; +} +``` + +## Animations + +Smooth transitions: +```css +.node { + transition: all 0.3s ease; +} + +.link { + transition: stroke-width 0.2s ease, stroke-opacity 0.2s ease; +} +``` + +## Tooltips + +Styled tooltips for data display: +```css +.tooltip { + position: absolute; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + pointer-events: none; + z-index: 1000; +} +``` + +## Development Guidelines + +When modifying CSS: + +1. **Test in web view** - Not just browser (Qt WebEngine may differ) +2. **Use CSS variables** - For easy theme changes +3. **Mobile-first** - Design for smallest screens first +4. **Performance** - Avoid expensive effects on many elements +5. **Accessibility** - Maintain contrast ratios (WCAG AA) +6. **Cross-browser** - Test in different rendering engines +7. **Documentation** - Comment complex selectors +8. **Organization** - Group related styles +9. **Naming** - Use clear, descriptive class names +10. **Validation** - Run through CSS validator + +## CSS Variables + +Use CSS custom properties for theming: +```css +:root { + --primary-color: #4a90e2; + --text-color: #333; + --background-color: #fff; + --hover-opacity: 0.8; +} + +.node { + fill: var(--primary-color); +} +``` + +## Browser Compatibility + +Ensure compatibility with Qt WebEngine: +- Test rendering in actual application +- Check vendor prefixes +- Verify CSS feature support +- Test on all platforms (Windows, macOS, Linux) + +## Resources + +- [MDN CSS Reference](https://developer.mozilla.org/en-US/docs/Web/CSS) +- [CSS-Tricks](https://css-tricks.com/) +- [Can I Use](https://caniuse.com/) - Feature compatibility +- [D3.js Styling](https://d3js.org/) - SVG styling patterns diff --git a/activity_browser/static/icons/README.md b/activity_browser/static/icons/README.md new file mode 100644 index 000000000..ec6d698ec --- /dev/null +++ b/activity_browser/static/icons/README.md @@ -0,0 +1,214 @@ +# icons + +Application icons and graphical assets. + +## Overview + +This directory contains all icon files used throughout Activity Browser, including the application icon, toolbar icons, menu icons, and node type indicators. + +## Directory Structure + +- **`main/`** - Main application icon in various sizes and formats +- **`context/`** - Context menu icons +- **`nodes/`** - Node type icons (for graph visualizations) +- **`metaprocess/`** - Meta-process related icons + +## File Formats + +Icons are typically provided in multiple formats: +- **PNG** - Raster format with transparency (various sizes: 16x16, 24x24, 32x32, 48x48, 256x256) +- **SVG** - Vector format (scalable without quality loss) +- **ICO** - Windows icon format (contains multiple sizes) +- **ICNS** - macOS icon format + +## main/ + +Main application icon used for: +- Application window icon +- Taskbar/dock icon +- Desktop shortcut icon +- About dialog +- Installer icon + +Sizes provided: +- 16x16 - Taskbar, title bar +- 24x24 - Small toolbar buttons +- 32x32 - Medium toolbar buttons, list views +- 48x48 - Large icons +- 256x256 - High DPI displays, splash screen +- 512x512 - macOS Retina displays + +## context/ + +Icons for context menu actions: +- Copy +- Paste +- Delete +- Edit +- Open +- Save +- Export +- Import +- Search +- Refresh +- Settings + +## nodes/ + +Icons representing different node types in graphs: +- Activity nodes +- Product nodes +- Biosphere flow nodes +- Technosphere flow nodes +- Waste flow nodes +- Substitution nodes + +## metaprocess/ + +Icons for meta-process operations: +- Aggregation +- Disaggregation +- Grouping +- Filtering + +## Icon Loading + +Icons are loaded via `activity_browser/ui/icons.py`: + +```python +from activity_browser.ui.icons import get_icon + +# Load icon by name +icon = get_icon("save") + +# Use in action +action = QAction(get_icon("open"), "Open", parent) + +# Use in button +button = QPushButton(get_icon("delete"), "Delete") +``` + +## Icon Themes + +Activity Browser may support multiple icon themes: +- Light theme (dark icons on light background) +- Dark theme (light icons on dark background) +- High contrast theme (for accessibility) + +## Icon Design Guidelines + +When creating or modifying icons: + +### Size and Resolution +- Provide multiple sizes (16, 24, 32, 48, 256) +- Ensure clarity at smallest size (16x16) +- Use even dimensions for pixel-perfect rendering +- Support high DPI (2x, 3x scales) + +### Style Consistency +- Match existing icon style +- Use consistent line weights +- Maintain similar level of detail +- Use the same color palette + +### Visual Clarity +- Simple, recognizable shapes +- Clear at small sizes +- Sufficient contrast +- Not too much detail + +### Accessibility +- Work in light and dark themes +- Sufficient contrast ratios +- Distinct shapes (not just color differences) +- Test with colorblindness simulators + +### File Optimization +- Optimize PNG files (use tools like pngcrush) +- Clean up SVG files (remove unnecessary elements) +- Use transparency appropriately +- Keep file sizes small + +## Color Palette + +Standard colors used in Activity Browser icons: +- **Primary**: Blue (#4a90e2) +- **Success**: Green (#2ecc71) +- **Warning**: Orange (#f39c12) +- **Error**: Red (#e74c3c) +- **Info**: Cyan (#3498db) +- **Neutral**: Gray (#95a5a6) + +## Platform-Specific Icons + +### Windows +- Use ICO format for application icon +- Provide 16, 32, 48, 256 sizes in single ICO file +- Follow Windows icon guidelines + +### macOS +- Use ICNS format for application icon +- Provide 16, 32, 128, 256, 512, 1024 sizes +- Follow macOS icon guidelines +- Support Retina displays + +### Linux +- Use PNG for application icon +- Provide standard sizes: 16, 24, 32, 48, 64, 128, 256 +- Follow freedesktop.org icon naming spec +- Install to appropriate directories + +## Icon Attribution + +If using third-party icons: +- Check license compatibility (LGPL-compatible) +- Provide attribution if required +- Document source and license +- Consider creating custom icons instead + +## Tools for Icon Creation + +Recommended tools: +- **Inkscape** - Free vector graphics editor (SVG) +- **GIMP** - Free raster graphics editor (PNG) +- **ImageMagick** - Batch processing and conversion +- **icon-resizer** - Generate multiple sizes from SVG + +## Updating Icons + +When updating icons: +1. Edit source SVG file +2. Export to required PNG sizes +3. Optimize files +4. Generate platform-specific formats (ICO, ICNS) +5. Update icons in all directories +6. Test in application on all platforms +7. Verify high DPI rendering +8. Check light and dark themes + +## Icon Resources + +Free icon sources (check licenses): +- [Font Awesome](https://fontawesome.com/) +- [Material Icons](https://material.io/icons/) +- [Feather Icons](https://feathericons.com/) +- [Heroicons](https://heroicons.com/) + +## Testing Icons + +Test icons: +- At all sizes (16px to 256px) +- On different backgrounds +- With different themes +- On high DPI displays +- On all platforms +- In actual UI contexts + +## Maintenance + +Keep icons: +- Up-to-date with design trends +- Consistent with application style +- Optimized for performance +- Properly licensed +- Version controlled diff --git a/activity_browser/static/icons/exchanges/link.png b/activity_browser/static/icons/exchanges/link.png new file mode 100644 index 0000000000000000000000000000000000000000..7742c7dbfda87df57e5704803d60abac87eb5733 GIT binary patch literal 31393 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
w&qgc~o{km?4j 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 literal 0 HcmV?d00001 diff --git a/activity_browser/static/icons/exchanges/unlink.png b/activity_browser/static/icons/exchanges/unlink.png new file mode 100644 index 0000000000000000000000000000000000000000..6d681334f621d6b740be25830640987117d43533 GIT binary patch literal 34142 zcmXtA1yEGq+uo&1Qb1Y+ge4?@NP~cYq_hYKNJ)1{?W%N&pfre-bSWS$DM-4Mf^_%N zx%-{;n8E zc2_g;fYX%`N)|dkTA+e$`1_uGk3BG{gVw(Fm0z;?SvipjVyyqdi?}i$juD_gqB{vPkLcF{(d#(qBX<`{AAP|we_S%S`s3xZuMl7 zw@+1D2NoeWI-$&WLnX}AU*1A2K>95O>mI@82=?nJ7X=`DXV>U@M@L6-(-z^?EvtVQ z5|Fo_p_XE#rKNR7e6Bem+&B_Y1viNdEyy#R{ZGjUVFtX%zus|aJT05OdC0I00egQk z4>2$_Gz1%uOcuehEGtJ^KgZNFT7n)i``b{lY&rpRzy(g>59nuQ%fk~ z(#IU$Tv2h)1>7C-@9u$*#296j)g@`$3k(w6jzeTR0@%Aa6eFd<3z+*7AgUeC0tvTD zm9;!r?rSYKtC}yJh2*|xYMJIC=f$4(eQ+%9P zZ4+Z-Wz_+qz7hXc&JuE+X<$#&TrJY;{|P-SpQWf(^~Uj6BSK|7;Xi2|ļ-*YSY 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 literal 0 HcmV?d00001 diff --git a/activity_browser/static/icons/main/star.png b/activity_browser/static/icons/main/star.png new file mode 100644 index 0000000000000000000000000000000000000000..3269055490ae08c4c768ae044aa57c6cf279d66a GIT binary patch literal 1187 zcmV;U1YG-xP)7uw5>AqNZE(+~NNWq0l4Q6AZLb13A5-7CLg`}3`wQ2lQBN2&BlbkN* zzVJxzS zH3gJX%|HbZpi4wnecYqJFR z2e~#w7Zs4S`Gu_vyqhtW4dy}sn}(KqGt6}gD5bi9TbK*I3UrFdLUySBZ+TZh+U6Iw zX5dIhSRyS|1t_Ijfo;IE`fr?9zaIdew#2vuTmu$E{&&Q&MPN}xmJ@RV-X>Umg~3Pp zR!cIj>^|`V)gG@vaFwdNvzy5)zyNv*>;$F@0Lqn51MMOLB2pEh75KIQ;PUnhKNW0)G_%ysh5@-i+lL_k|W= z7cf%*5~ltO@KRll^}f_1YzM9sfJ|Dy3_RD6U!xPW2wQ>i0+34TUjSPYaZ7SijSwdZ zp5yr@80YK2$Ei4_Ia!PF0kB*EvSI%hIFy=C+5uXGy}&{NNLPIxc+H55$sjF48Ms~m z@>hWujCq(2)FM0seD9I`cfeCt9Bc+_5t@Lr9>{+Vlx*=^?+8ioJpYPq90U7NGT1TB zYD?^`jN&-YnUw&88O3p)(|T7}@BBJ{xk=meBJ#UUfGjG&NBL)#`-uQW0O2#YRS!@~ z^%A`B=+ipH;xW2y1^B%BRkOnIL;#P;mw`{T|7Q`$ZarXe53mdKb^IluuO`-uzz-Jj z%Fb_wfa3;0{{V*T#2g0xG>H454*|YP33420OAxmWIG;AQ4*`Bl2yO=0XAu7#f}hwV zIp&-S0KAd_)CzD4Xt5Bv1vm{nNRY43D2E}i6Ai#j0k4`8kI1(h__;yOQI`T-hyk&r z#j)P0iO5+3J_7!U$^E4h0SMkIxfcOof?!#iDe;K>oxo&7{&`md41^Nj1`g&;JOamI z;BE*@f7&=EE5YjOZyh)TyeuLU263!RM7{y~fpY}pYq6yYlvepkg69FRyT0mmFueiX z0KRrn0T+QiA~Iz#HWw3-p9rSr<+NkH{qJy5>>+6D QtWidgets.QMainWindow: + """Returns the main_window widget of the Activity Browser""" + if self._main_window: + return self._main_window + raise Exception( + "main_window not yet initialized, did you try to access it during startup?" + ) + + @main_window.setter + def main_window(self, widget: QtWidgets.QMainWindow): + self._main_window = widget + + # connect global keyboard shortcuts to their respective functions + for seq, func in _global_shortcuts.items(): + shortcut = QtWidgets.QShortcut(QtGui.QKeySequence(seq), widget) + shortcut.activated.connect(func) + + def show(self): + self.main_window.showMaximized() + + def close(self): + for child in self.children(): + if hasattr(child, "close"): + child.close() + + def deleteLater(self): + self.main_window.deleteLater() + + +def global_shortcut(key_sequence): + """ + Decorator to register a global keyboard shortcut for the main window. Decorate a function with e.g. + @global_shortcut("Ctrl+S") to register it as a shortcut. Also works on the run method of actions as long as the + parameters of said action are taken care of. + """ + def decorator(func): + _global_shortcuts[key_sequence] = func + return func + return decorator + +_global_shortcuts = {} diff --git a/activity_browser/ui/core/threading.py b/activity_browser/ui/core/threading.py index 6de0f0818..d2ce05b23 100644 --- a/activity_browser/ui/core/threading.py +++ b/activity_browser/ui/core/threading.py @@ -1,5 +1,5 @@ import threading -import logging +from loguru import logger from qtpy.QtCore import QThread, SignalInstance, Signal from qtpy import QtWidgets @@ -15,8 +15,8 @@ class ABThread(QThread): def __init__(self, parent=None): super().__init__(parent) - from activity_browser import application - self.exception.connect(application.main_window.dialog_on_exception) + from activity_browser import app + self.exception.connect(app.main_window.dialog_on_exception) def start(self, *args, priority=QThread.NormalPriority, **kwargs): """ @@ -84,30 +84,38 @@ def __exit__(self, *args): class InfoToSlot: def __init__(self, progress_slot=lambda progress, message: None): - self.handler = LoggingProgressHandler("INFO") + self.sink = LoggingProgressSink("INFO") thread_local.progress_slot = progress_slot + self._sink_id = None def __enter__(self): - logging.root.addHandler(self.handler) + # Attach a loguru sink which forwards INFO logs from this thread to the progress slot + self._sink_id = logger.add(self.sink, level="INFO") return def __exit__(self, *args): - logging.root.removeHandler(self.handler) + if self._sink_id is not None: + try: + logger.remove(self._sink_id) + except Exception: + pass return +class LoggingProgressSink: + def __init__(self, level="INFO"): + self.level = level -class LoggingProgressHandler(logging.Handler): - def filter(self, record: logging.LogRecord) -> bool: - if record.thread != threading.get_ident(): - return False - if record.levelname != "INFO": - return False - return True - - def emit(self, record: logging.LogRecord): + def __call__(self, message): + record = message.record try: - thread_local.progress_slot(None, record.message) + # Only handle messages from the current thread and matching level + if record["level"].name != self.level: + return + if record["thread"].id != threading.get_ident(): + return + thread_local.progress_slot(None, record.get("message", "")) except AttributeError: + # No progress slot set or malformed record pass diff --git a/activity_browser/ui/core/tree_model.py b/activity_browser/ui/core/tree_model.py new file mode 100644 index 000000000..86a020446 --- /dev/null +++ b/activity_browser/ui/core/tree_model.py @@ -0,0 +1,585 @@ +from typing import Optional +from loguru import logger + +import pandas as pd + +from PySide6 import QtGui +from PySide6.QtCore import QModelIndex, Qt, QAbstractItemModel +from PySide6.QtWidgets import QWidget + +from activity_browser.ui.icons import qicons + + +class TreeNode: + """ + Optimized node object that combines children_map, row_indices, loaded_counts, + and DataFrame position for O(1) lookups. + """ + __slots__ = ('path', 'children', 'row_in_parent', 'loaded_count', 'df_position', 'is_leaf', '_child_lookup') + + def __init__(self, path: tuple, df_position: int = -1): + self.path: tuple = path # Full path tuple for this node + self.children: list['TreeNode'] = [] # List of child nodes + self.row_in_parent: int = -1 # Row index within parent's children list + self.loaded_count: int = 0 # Number of children currently loaded (for lazy loading) + self.df_position: int = df_position # Integer position in DataFrame (-1 for branch nodes) + self.is_leaf: bool = (df_position >= 0) # True if this is a leaf node + self._child_lookup: dict[tuple, TreeNode] = {} # Fast child lookup by path + + def add_child(self, child: 'TreeNode') -> None: + """Add a child node and update its row_in_parent.""" + child.row_in_parent = len(self.children) + self.children.append(child) + self._child_lookup[child.path] = child + + def get_child(self, path: tuple) -> Optional['TreeNode']: + """Get a child by its path (O(1) lookup).""" + return self._child_lookup.get(path) + + def get_child_at(self, row: int) -> Optional['TreeNode']: + """Get a child by its row index (O(1) lookup).""" + if 0 <= row < len(self.children): + return self.children[row] + return None + + def total_children(self) -> int: + """Total number of children (for lazy loading comparison).""" + return len(self.children) + + def can_fetch_more(self) -> bool: + """Check if more children can be loaded.""" + return self.loaded_count < len(self.children) + + +class ABTreeModel(QAbstractItemModel): + def __init__(self, + df: pd.DataFrame = None, + parent: Optional[QWidget] = None, + chunk_size: int = -1, + enable_sorting: bool = False + ) -> None: + super().__init__(parent) + self.df = df if df is not None else pd.DataFrame() + self.df.index = pd.MultiIndex.from_arrays([range(len(self.df))], names=[f"index"]) + + self.df_query: dict[str, str] = {"model": "index == index"} # dictionary where queries can be registered + self.filtered_columns: set[int] = set() # set of column indices that have active filters, only used for the header icon + self.grouped_columns: list[str] = [] # list of columns currently used for grouping + + self.sorted_column: str | None = None + self.sort_order = Qt.SortOrder.AscendingOrder + self.sorting_enabled = enable_sorting + + self.lazy = chunk_size > 0 + self.chunk_size = chunk_size + + # Single unified node map: path -> TreeNode + self.node_map: dict[tuple, TreeNode] = {} + self.root: TreeNode = TreeNode(tuple()) # Root node with empty path + + # Build the node hierarchy + self.build_node_hierarchy(self.df.index) + + def columns(self) -> list[str]: + """Return the list of column names, including the tree column.""" + return ["index"] + [col for col in self.df.columns if not col.startswith("_")] + + def column_name(self, index: QModelIndex) -> str: + """Return the name of the column at the given index, including the tree column.""" + return self.columns()[index.column()] + + def row(self, index: QModelIndex) -> pd.Series | None: + """ + Return the DataFrame row corresponding to the given index, or None for non-leaf nodes. + + Warning: This is a slow operation and should be avoided in methods called frequently like data(), *Data(), flags(), or index*(). + """ + if not index.isValid(): + return None + + node = index.internalPointer() + + if not isinstance(node, TreeNode) or not node.is_leaf: + return None + + # Use the pre-computed df_position for fast access + return self.df.iloc[node.df_position] + + def get(self, index: QModelIndex, column: str | int) -> any: + """ + Get the data for the given QModelIndex and column name or index. + """ + if not index.isValid(): + return None + + node = index.internalPointer() + + if not isinstance(node, TreeNode) or not node.is_leaf: + return None + + column_i = column if isinstance(column, int) else self.df.columns.get_loc(column) + + return self.df.iat[node.df_position, column_i] + + + # --- required model overrides --- + def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex: + parent_node = parent.internalPointer() if parent.isValid() else self.root + + if not isinstance(parent_node, TreeNode): + parent_node = self.root + + child_node = parent_node.get_child_at(row) + + if child_node is None: + return QModelIndex() + + return self.createIndex(row, column, child_node) + + def parent(self, index: QModelIndex) -> QModelIndex: + if not index.isValid(): + return QModelIndex() + + node = index.internalPointer() + if not isinstance(node, TreeNode): + return QModelIndex() + + parent_path = self.parent_path(node.path) + + if len(parent_path) == 0: + return QModelIndex() + + parent_node = self.node_map.get(parent_path) + if parent_node is None: + return QModelIndex() + + return self.createIndex(parent_node.row_in_parent, 0, parent_node) + + def parent_path(self, path: tuple) -> tuple: + path = tuple(val for val in path if not pd.isna(val)) + return path[:-1] + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + # For tree models, when the parent is valid and column > 0, return 0 + if parent.isValid() and parent.column() != 0: + return 0 + + parent_node = parent.internalPointer() if parent.isValid() else self.root + + if not isinstance(parent_node, TreeNode): + parent_node = self.root + + # Return the number of currently loaded children + return parent_node.loaded_count + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802 (Qt signature) + # Always return the full column count for consistent tree structure + return len(self.columns()) + + #--- data overrides --- + def data(self, index: QModelIndex, role: int = Qt.DisplayRole): + # if not index.isValid() or self.df.empty: + # return None + + if role == Qt.DisplayRole: + return self.displayData(index) + elif role == Qt.EditRole: + return self.editData(index) + elif role == Qt.UserRole: + return self.userData(index) + elif role == Qt.DecorationRole: + return self.decorationData(index) + elif role == Qt.FontRole: + return self.fontData(index) + elif role == Qt.ToolTipRole: + return self.toolTipData(index) + + return None + + def displayData(self, index: QModelIndex) -> any: + node = index.internalPointer() + + if not isinstance(node, TreeNode): + return None + + if not node.is_leaf: # branch node + # For branch nodes, show the name in the first column only + # (spanning will be handled by the view) + return node.path[-1] if index.column() == 0 else None + + if index.column() == 0: + return None # leaf node tree column is empty + + # Get the pandas column index (disregard hidden columns) + col_name = self.columns()[index.column()] + col_idx = self.df.columns.get_loc(col_name) + + val = self.df.iat[node.df_position, col_idx] + + if not hasattr(val, "__iter__") and pd.isna(val): + return None + + return val + + def editData(self, index: QModelIndex) -> any: + return self.displayData(index) + + def userData(self, index: QModelIndex) -> any: + return self.displayData(index) + + def decorationData(self, index: QModelIndex) -> any: + return None + + def fontData(self, index: QModelIndex) -> any: + return None + + def toolTipData(self, index: QModelIndex) -> any: + return None + + #--- flag overrides --- + def flags(self, index): + if not index.isValid(): + return Qt.ItemFlag.NoItemFlags + + flags = Qt.ItemFlag.NoItemFlags + if self.indexEnabled(index): + flags |= Qt.ItemFlag.ItemIsEnabled + if self.indexSelectable(index): + flags |= Qt.ItemFlag.ItemIsSelectable + if self.indexEditable(index): + flags |= Qt.ItemFlag.ItemIsEditable + if self.indexDragEnabled(index): + flags |= Qt.ItemFlag.ItemIsDragEnabled + if self.indexDropEnabled(index): + flags |= Qt.ItemFlag.ItemIsDropEnabled + if self.indexUserCheckable(index): + flags |= Qt.ItemFlag.ItemIsUserCheckable + return flags + + def indexEnabled(self, index: QModelIndex) -> bool: + return True + + def indexSelectable(self, index: QModelIndex) -> bool: + return True + + def indexEditable(self, index: QModelIndex) -> bool: + return False + + def indexDragEnabled(self, index: QModelIndex) -> bool: + return False + + def indexDropEnabled(self, index: QModelIndex) -> bool: + return False + + def indexUserCheckable(self, index: QModelIndex) -> bool: + return False + + def isBranchNode(self, index: QModelIndex) -> bool: + """Check if the given index represents a branch node (non-leaf).""" + if not index.isValid(): + return False + node = index.internalPointer() + if not isinstance(node, TreeNode): + return False + return not node.is_leaf + + def headerData(self, section: int, orientation: Qt.Orientation = Qt.Horizontal, role: int = Qt.DisplayRole): + if orientation == Qt.Vertical: + return None + + if role == Qt.DisplayRole: + if section == 0: + return "" + + return self.columns()[section] + + if role == Qt.ItemDataRole.FontRole and section in self.filtered_columns: + font = QtGui.QFont() + font.setUnderline(True) + return font + + if role == Qt.ItemDataRole.DecorationRole and section in self.filtered_columns: + return qicons.filter + + def canFetchMore(self, parent: QModelIndex) -> bool: + """Check if this parent has more children that can be loaded.""" + if not self.lazy: + return False + + parent_node = parent.internalPointer() if parent.isValid() else self.root + + if not isinstance(parent_node, TreeNode): + parent_node = self.root + + return parent_node.can_fetch_more() + + def fetchMore(self, parent: QModelIndex) -> None: + """Load the next chunk of children when user scrolls.""" + if not self.lazy: + return + + parent_node = parent.internalPointer() if parent.isValid() else self.root + + if not isinstance(parent_node, TreeNode): + parent_node = self.root + + total_children = parent_node.total_children() + currently_loaded = parent_node.loaded_count + + if currently_loaded >= total_children: + return # Everything already loaded + + # Calculate how many more to load + remaining = total_children - currently_loaded + to_load = min(self.chunk_size, remaining) + + # Notify view that we're about to add rows + first_new_row = currently_loaded + last_new_row = currently_loaded + to_load - 1 + + self.beginInsertRows(parent, first_new_row, last_new_row) + parent_node.loaded_count = currently_loaded + to_load + self.endInsertRows() + + # --- helper functions --- + def set_dataframe(self, df: pd.DataFrame, group: list[str] = None) -> None: + self.beginResetModel() + + self.df = df + self.grouped_columns = group or self.grouped_columns + + self.build_df_index() + self.apply_sort() + self.apply_filter() + + self.endResetModel() + + def update_dataframe(self, df: pd.DataFrame, group: list[str] = None) -> None: + self.layoutAboutToBeChanged.emit() + self.df = df + self.grouped_columns = group or self.grouped_columns + + self.build_df_index() + self.apply_sort() + self.apply_filter() + + self.layoutChanged.emit() + + def group(self, columns: list[str] = None) -> None: + self.layoutAboutToBeChanged.emit() + self.grouped_columns = columns or self.grouped_columns + + self.build_df_index() + self.apply_sort() + self.apply_filter() + + self.layoutChanged.emit() + + def ungroup(self) -> None: + self.layoutAboutToBeChanged.emit() + self.grouped_columns = [] + + self.build_df_index() + self.apply_sort() + self.apply_filter() + + self.layoutChanged.emit() + + def sort(self, column: int | str, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder) -> None: + if not self.sorting_enabled: + logger.warning(f"Called sort() on {self.__class__.__name__} with sorting disabled.") + return + + self.layoutAboutToBeChanged.emit() + + self.sorted_column = self.headerData(column) if isinstance(column, int) else column + self.sort_order = order + + self.apply_sort() + self.apply_filter() + + self.layoutChanged.emit() + + def filter(self, key: str = None, query: str = None) -> None: + """Filter the DataFrame based on a simple substring match across all columns.""" + self.layoutAboutToBeChanged.emit() + + if query is not None and key is not None: + self.df_query[key] = query + + self.apply_filter() + + self.layoutChanged.emit() + + def build_df_index(self): + # dataframe we will use to build the new index + df = self.df[self.grouped_columns].copy() + + # unpack iterables in the grouped columns + for col in self.grouped_columns: + # Check if the column contains iterables (excluding strings) + sample_val = df[col].dropna().iloc[0] if not df[col].dropna().empty else None + if not isinstance(sample_val, (list, tuple, set)): + continue + + # Unpack the iterable into separate columns and add to the dataframe + unpacked = pd.DataFrame(df[col].tolist(), index=df.index) + for i, unpacked_col in enumerate(unpacked.columns): + df[f"{col}_{i}"] = unpacked[unpacked_col] + + # Remove the original column from the dataframe + df = df.drop(columns=[col]) + + df = df.dropna(how='all', axis=1) + df["index"] = range(len(df)) + + new_index = pd.MultiIndex.from_frame(df) + new_index.names = [i + "_i" for i in new_index.names] + + self.df.index = new_index + + def reset_hierarchy(self, df: pd.DataFrame = None) -> None: + df = df if df is not None else self.df + old_persistent_indices = [(idx, idx.internalPointer()) for idx in self.persistentIndexList()] + + # Rebuild the node hierarchy + self.build_node_hierarchy(df.index) + + # Update persistent indexes + new_persistent = [] + for old_index, old_node in old_persistent_indices: + if isinstance(old_node, TreeNode): + # Try to find the same path in the new hierarchy + new_node = self.node_map.get(old_node.path) + if new_node is not None: + new_index = self.createIndex(new_node.row_in_parent, old_index.column(), new_node) + new_persistent.append(new_index) + else: + new_persistent.append(QModelIndex()) + else: + new_persistent.append(QModelIndex()) + + # Update the model's persistent indexes + self.changePersistentIndexList(self.persistentIndexList(), new_persistent) + + def build_node_hierarchy(self, pandas_index: pd.Index) -> None: + """ + Build the unified TreeNode hierarchy with all information combined: + - children relationships + - row indices + - loaded counts + - DataFrame positions + """ + self.root = TreeNode(tuple()) + self.node_map = {tuple(): self.root} + + # Convert index to frame once for all operations + idx_df = pandas_index.to_frame(index=False) + + # Create a mapping from full path to DataFrame position + path_to_position = {} + for row_tuple in idx_df.itertuples(index=False, name=None): + df_pos = self.df.index.get_loc(row_tuple) + path_to_position[row_tuple] = df_pos + + # Process each level to build the hierarchy + for level in range(idx_df.shape[1]): + # Get unique child paths at this level (as tuples) + child_paths = idx_df.iloc[:, :level + 1].drop_duplicates() + child_tuples = list(child_paths.itertuples(index=False, name=None)) + + for child_path in child_tuples: + if pd.isna(child_path[-1]): + continue # skip NaN children + + # Skip if we've already created this node + if child_path in self.node_map: + continue + + # Determine parent path + if level == 0: + parent_path = tuple() + else: + parent_path = tuple(val for val in child_path[:-1] if not pd.isna(val)) + + # Get or create parent node + parent_node = self.node_map.get(parent_path) + if parent_node is None: + parent_node = self.root + + # Check if this is a leaf node (full depth) + is_leaf = (level == idx_df.shape[1] - 1) + df_position = path_to_position.get(child_path, -1) if is_leaf else -1 + + # Create the child node + child_node = TreeNode(child_path, df_position) + + # Add child to parent + parent_node.add_child(child_node) + + # Store in node map + self.node_map[child_path] = child_node + + # Initialize loaded counts + if self.lazy: + # Load first chunk for each node + for node in self.node_map.values(): + node.loaded_count = min(self.chunk_size, node.total_children()) + else: + # All children loaded + for node in self.node_map.values(): + node.loaded_count = node.total_children() + + def apply_filter(self): + pandas_query = " & ".join(self.df_query.values()) + filtered_df = self.df.query(pandas_query) + self.reset_hierarchy(filtered_df) + + def apply_sort(self): + if self.df.empty or not self.sorting_enabled: + return + + logger.debug(f"Applying sorting in : {self.__class__.__name__}") + + # Extract the unique order of higher levels + higher_levels = self.df.index.droplevel(-1).unique() if self.df.index.nlevels > 1 else [None] + + # Build a new index by sorting only within each higher level + sorted_index = [] + + for lvl in higher_levels: + mask = self.df.index.droplevel(-1) == lvl if lvl is not None else self.df.index + partial_df = self.df.loc[mask, self.sorted_column or self.df.columns[0]].copy() + if self.sorted_column is not None: + partial_df.sort_values(ascending=(self.sort_order == Qt.SortOrder.AscendingOrder), inplace=True) + else: + partial_df = partial_df.sort_index(ascending=(self.sort_order == Qt.SortOrder.AscendingOrder)) + sorted_index.append(partial_df.index) + + sorted_index = sorted_index[0].append(sorted_index[1:]) # Flatten + self.df = self.df.loc[sorted_index] # Update dataframe to new sorted order + + def values_from_indices(self, key: str, indices: list[QModelIndex]): + """ + Returns the values from the given indices. + + Args: + key (str): The key to get the values for. + indices (list[QtCore.QModelIndex]): The indices to get the values for. + + Returns: + list: The list of values. + """ + df_positions = [] + for index in indices: + if not index.isValid(): + continue + node = index.internalPointer() + if isinstance(node, TreeNode) and node.is_leaf: + df_positions.append(node.df_position) + + if not df_positions: + return [] + + return self.df.iloc[df_positions][key].tolist() + diff --git a/activity_browser/ui/delegates/README.md b/activity_browser/ui/delegates/README.md new file mode 100644 index 000000000..099aaa61e --- /dev/null +++ b/activity_browser/ui/delegates/README.md @@ -0,0 +1,138 @@ +# delegates + +Qt item delegates for custom cell rendering and editing in tables and trees. + +## Overview + +This directory contains custom Qt delegates that control how data is displayed and edited in table and tree views throughout Activity Browser. Delegates enable specialized rendering, validation, and editing behavior for different data types. + +## What are Delegates? + +In Qt's Model/View architecture, delegates handle: +- **Display** - How data appears in cells (colors, icons, formatting) +- **Editing** - What widget appears when user edits a cell +- **Validation** - Checking user input before accepting +- **Decoration** - Adding icons, colors, or other visual elements + +## Usage Pattern + +Assign delegates to specific columns by defining them in the `defaultColumnDelegates` attribute of the `ABTreeView` class: + +```python +class View(widgets.ABTreeView): + """ + A view that displays the exchanges in a tree structure. + + Attributes: + defaultColumnDelegates (dict): The default column delegates for the view. + hovered_item (ExchangesItem): The item currently being hovered over. + """ + defaultColumnDelegates = { + "column_name": delegates.DelegateYouWantToUse, + } +``` + + +## Creating Custom Delegates + +Inherit from `QStyledItemDelegate`: + +```python +from qtpy.QtWidgets import QStyledItemDelegate, QLineEdit + +class MyDelegate(QStyledItemDelegate): + def createEditor(self, parent, option, index): + """Create the editing widget.""" + editor = QLineEdit(parent) + editor.setValidator(...) # Add validation + return editor + + def setEditorData(self, editor, index): + """Load data into editor.""" + value = index.data(Qt.EditRole) + editor.setText(str(value)) + + def setModelData(self, editor, model, index): + """Save editor data back to model.""" + value = editor.text() + model.setData(index, value, Qt.EditRole) + + def displayText(self, value, locale): + """Format value for display.""" + return f"{value:.2f}" +``` + +## Key Methods + +### `createEditor(parent, option, index)` +Creates the widget used for editing: +- **parent** - Parent widget for the editor +- **option** - Style options for the item +- **index** - Model index being edited +- **Returns** - Editor widget (QLineEdit, QComboBox, etc.) + +### `setEditorData(editor, index)` +Populates the editor with current value: +- **editor** - The editor widget +- **index** - Model index with data + +### `setModelData(editor, model, index)` +Saves edited value back to model: +- **editor** - The editor widget +- **model** - The data model +- **index** - Model index to update + +### `displayText(value, locale)` +Formats value for display (optional): +- **value** - Raw data value +- **locale** - Locale for formatting +- **Returns** - Formatted string + +### `paint(painter, option, index)` +Custom rendering (advanced): +- **painter** - QPainter for drawing +- **option** - Style options +- **index** - Model index to render + +## Validation + +Add validators to editors: + +```python +def createEditor(self, parent, option, index): + editor = QLineEdit(parent) + validator = QDoubleValidator(0.0, 1000.0, 2, editor) + editor.setValidator(validator) + return editor +``` + +## Signal Handling + +Delegates can emit signals on edits: + +```python +from qtpy.QtCore import Signal + +class MyDelegate(QStyledItemDelegate): + editingFinished = Signal(QModelIndex, object) + + def setModelData(self, editor, model, index): + value = editor.text() + model.setData(index, value) + self.editingFinished.emit(index, value) +``` + +## Development Guidelines + +When creating delegates: + +1. **Inherit from QStyledItemDelegate** - Preferred over QItemDelegate +2. **Validate input** - Add QValidator to editors +3. **Handle edge cases** - Empty values, invalid data, cancellation +4. **Match data types** - Editor should match model data type +5. **Close editor properly** - Emit closeEditor signal when done +6. **Keep it simple** - Complex editing might need a dialog +7. **Test thoroughly** - Verify editing, validation, display +8. **Consider performance** - Efficient for many cells +9. **Support keyboard** - Tab, Enter, Escape navigation +10. **Provide feedback** - Visual cues for invalid input diff --git a/activity_browser/ui/delegates/__init__.py b/activity_browser/ui/delegates/__init__.py index 6ff8fd045..c80635da9 100644 --- a/activity_browser/ui/delegates/__init__.py +++ b/activity_browser/ui/delegates/__init__.py @@ -1,10 +1,8 @@ # -*- coding: utf-8 -*- from .checkbox import CheckboxDelegate from .combobox import ComboBoxDelegate -from .database import DatabaseDelegate from .delete_button import DeleteButtonDelegate from .float import FloatDelegate -from .formula import FormulaDelegate from .json import JSONDelegate from .list import ListDelegate from .string import StringDelegate @@ -15,16 +13,15 @@ from .date_time import DateTimeDelegate from .property import PropertyDelegate from .amount import AmountDelegate, AbsoluteAmountDelegate +from .card import CardDelegate __all__ = [ "AmountDelegate", "AbsoluteAmountDelegate", "CheckboxDelegate", "ComboBoxDelegate", - "DatabaseDelegate", "DeleteButtonDelegate", "FloatDelegate", - "FormulaDelegate", "JSONDelegate", "ListDelegate", "StringDelegate", @@ -35,4 +32,5 @@ "NewFormulaDelegate", "DateTimeDelegate", "PropertyDelegate", + "CardDelegate", ] diff --git a/activity_browser/ui/delegates/card.py b/activity_browser/ui/delegates/card.py new file mode 100644 index 000000000..58218e422 --- /dev/null +++ b/activity_browser/ui/delegates/card.py @@ -0,0 +1,192 @@ +from typing import TypedDict + +from qtpy import QtCore, QtWidgets, QtGui +from qtpy.QtCore import Qt + + +class CardData(TypedDict): + title: str + subtitle: str | None + detail: str | None + categories: list[str] | None + + +class CardDelegate(QtWidgets.QStyledItemDelegate): + """Delegate for rendering card-like items with title, subtitle, categories and background icon.""" + + PADDING = 8 + MARGIN = 2 + TITLE_LINES = 2 + ICON_OPACITY = 0.3 + + def sizeHint(self, option, index): + if index.data() is None: + return super().sizeHint(option, index) + + # Calculate text heights + fm = option.fontMetrics + line_height = fm.height() + + # Title (2 lines, larger font) + title_height = int(line_height * 1 * self.TITLE_LINES) + 5 + + # Subtitle + subtitle_height = int(line_height * 0.9) # 0.9x for smaller font + + # Categories + categories_height = 7 + int(line_height * 0.8) + + # Total height with padding + total_height = (self.PADDING * 2 + self.MARGIN * 2 + + title_height + subtitle_height + categories_height) + + return QtCore.QSize(option.rect.width(), max(total_height, 40)) + + def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): + if index.data() is None: + super().paint(painter, option, index) + return + + painter.save() + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + + card_data = index.data() + is_selected = option.state & QtWidgets.QStyle.StateFlag.State_Selected + font_size = option.font.pointSize() + + # Draw background and border + rect = option.rect.adjusted(self.MARGIN, self.MARGIN, -self.MARGIN, -self.MARGIN) + + # Background + painter.fillRect(rect, option.palette.base()) + + # Border + border_color = option.palette.highlight() if is_selected else option.palette.mid() + painter.setPen(QtGui.QPen(border_color, 1)) + painter.drawRoundedRect(rect, 3, 3) + + # Draw background icon + icon = index.data(Qt.ItemDataRole.DecorationRole) + icon_size = 0 + if icon and not icon.isNull(): + painter.setOpacity(self.ICON_OPACITY) + icon_size = int(rect.height() * 0.8) + icon_x = rect.right() - icon_size - 10 + icon_y = rect.top() + (rect.height() - icon_size) // 2 + icon.paint(painter, icon_x, icon_y, icon_size, icon_size) + painter.setOpacity(1.0) + + # Setup text area + text_rect = rect.adjusted(self.PADDING, self.PADDING, -self.PADDING, -self.PADDING) + y = text_rect.top() + + # Draw title (bold, larger, 2 lines) + title = card_data.get('title', '') + title_font = option.font + title_font.setPointSize(int(option.font.pointSize() * 1)) + title_font.setWeight(QtGui.QFont.Weight.DemiBold) + painter.setFont(title_font) + painter.setPen(option.palette.text().color()) + + title_fm = QtGui.QFontMetrics(title_font) + title_height = 5 + title_fm.height() * self.TITLE_LINES + title_rect = QtCore.QRect(text_rect.left(), y, text_rect.width(), title_height) + + # Elide title text if it's too long for 2 lines + title_text = str(title) + max_width = title_rect.width() + + # Split into words and fit within 2 lines with eliding + words = title_text.split() + line1_words = [] + line2_words = [] + current_line = line1_words + + for word in words: + test_text = " ".join(current_line + [word]) + if title_fm.horizontalAdvance(test_text) <= max_width: + current_line.append(word) + elif current_line is line1_words and len(line2_words) == 0: + # Move to second line + current_line = line2_words + current_line.append(word) + else: + # Need to elide + break + + line1_text = " ".join(line1_words) + line2_text = " ".join(line2_words) + + # If there are remaining words, elide the second line + if len(line1_words) + len(line2_words) < len(words): + line2_text = title_fm.elidedText(title_text if not line1_text else " ".join(words[len(line1_words):]), + Qt.TextElideMode.ElideRight, max_width) + + # Draw title lines + painter.drawText(title_rect.left(), title_rect.top() + title_fm.ascent(), line1_text) + if line2_text: + painter.drawText(title_rect.left(), title_rect.top() + title_fm.ascent() + title_fm.height(), line2_text) + + y += title_height + + # Draw subtitle (smaller) + subtitle = card_data.get('subtitle', '') + if subtitle: + subtitle_font: QtGui.QFont = option.font + subtitle_font.setPointSize(int(font_size * 0.9)) + subtitle_font.setWeight(QtGui.QFont.Weight.Light) + painter.setFont(subtitle_font) + + subtitle_fm = QtGui.QFontMetrics(subtitle_font) + subtitle_height = subtitle_fm.height() + subtitle_rect = QtCore.QRect(text_rect.left(), y, text_rect.width(), subtitle_height) + + # Elide subtitle if too long + subtitle_text = subtitle_fm.elidedText(str(subtitle), Qt.TextElideMode.ElideRight, subtitle_rect.width()) + painter.drawText(subtitle_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, subtitle_text) + y += subtitle_height + + # Draw detail (bottom left) + detail = card_data.get('detail', '') + detail_width = 0 + if detail: + detail_font = option.font + detail_font.setPointSize(int(font_size * 0.8)) + painter.setFont(detail_font) + + detail_fm = QtGui.QFontMetrics(detail_font) + detail_height = detail_fm.height() + + # Reserve half width for detail, half for categories + max_detail_width = text_rect.width() // 2 - 10 + detail_rect = QtCore.QRect(text_rect.left(), text_rect.bottom() - detail_height, + max_detail_width, detail_height) + + # Elide detail if too long + detail_text_elided = detail_fm.elidedText(str(detail), Qt.TextElideMode.ElideRight, max_detail_width) + painter.drawText(detail_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, detail_text_elided) + detail_width = detail_fm.horizontalAdvance(detail_text_elided) + 10 + + # Draw categories (pipe separated, bottom right) + categories = card_data.get('categories', []) + if categories and isinstance(categories, (list, tuple)): + categories_text = " | ".join(str(cat) for cat in categories) + categories_font = option.font + categories_font.setPointSize(int(font_size * 0.8)) + painter.setFont(categories_font) + + categories_fm = QtGui.QFontMetrics(categories_font) + categories_height = categories_fm.height() + + # Adjust width to account for detail on left + available_width = text_rect.width() - detail_width + categories_rect = QtCore.QRect(text_rect.left() + detail_width, text_rect.bottom() - categories_height, + available_width, categories_height) + + # Elide categories if too long + categories_text_elided = categories_fm.elidedText(categories_text, Qt.TextElideMode.ElideRight, available_width) + painter.drawText(categories_rect, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, categories_text_elided) + + painter.restore() + + diff --git a/activity_browser/ui/delegates/new_formula.py b/activity_browser/ui/delegates/new_formula.py index db929ff8c..b118624bb 100644 --- a/activity_browser/ui/delegates/new_formula.py +++ b/activity_browser/ui/delegates/new_formula.py @@ -1,3 +1,4 @@ +from loguru import logger from qtpy import QtCore, QtWidgets from qtpy.QtGui import QFontMetrics, QFont from qtpy.QtCore import Qt @@ -30,12 +31,14 @@ def paint(self, painter, option: QtWidgets.QStyleOptionViewItem, index): if hasattr(index.internalPointer(), 'scoped_parameters'): scope = index.internalPointer().scoped_parameters + elif hasattr(index.model(), 'scoped_parameters'): + scope = index.model().scoped_parameters(index) else: scope = {} from activity_browser.ui.widgets import ABFormulaEdit viewport = self.parent().findChild(QtWidgets.QWidget, "qt_scrollarea_viewport") - formula = ABFormulaEdit(viewport, scope, index.data()) + formula = ABFormulaEdit(viewport, scope, index.data(), simple=True) painter.setClipRect(option.rect) painter.translate(option.rect.topLeft()) @@ -49,6 +52,8 @@ def createEditor(self, parent, option, index): from activity_browser.ui.widgets import ABFormulaEdit if hasattr(index.internalPointer(), 'scoped_parameters'): scope = index.internalPointer().scoped_parameters + elif hasattr(index.model(), 'scoped_parameters'): + scope = index.model().scoped_parameters(index) else: scope = {} editor = ABFormulaEdit(parent, scope) diff --git a/activity_browser/ui/delegates/string.py b/activity_browser/ui/delegates/string.py index 4ded24422..2708f2336 100644 --- a/activity_browser/ui/delegates/string.py +++ b/activity_browser/ui/delegates/string.py @@ -7,6 +7,7 @@ class StringDelegate(QtWidgets.QStyledItemDelegate): def displayText(self, value, locale): if isinstance(value, (list, tuple)): + value = [str(v) for v in value] return ", ".join(value) return str(value) diff --git a/activity_browser/ui/delegates/uncertainty.py b/activity_browser/ui/delegates/uncertainty.py index 52ff819c1..cb782e719 100644 --- a/activity_browser/ui/delegates/uncertainty.py +++ b/activity_browser/ui/delegates/uncertainty.py @@ -2,9 +2,7 @@ from qtpy import QtCore, QtWidgets from stats_arrays import uncertainty_choices as uc -from activity_browser import actions - -from activity_browser.signals import signals +from activity_browser.ui.dialogs import UncertaintyDialog class UncertaintyDelegate(QtWidgets.QStyledItemDelegate): @@ -12,46 +10,50 @@ class UncertaintyDelegate(QtWidgets.QStyledItemDelegate): `setModelData` stores the integer id of the selected uncertainty distribution. """ - - def __init__(self, parent=None): - super().__init__(parent) - uc.check_id_uniqueness() - self.choices = {u.description: u.id for u in uc.choices} - def displayText(self, value, locale): """Take the given integer id and return the description. Will return the 'Unknown' uncertainty description if the given id either cannot be found or the value is 'nan' (when id is not set) """ - try: - return uc[int(value)].description - except (IndexError, ValueError): - return uc[0].description + if isinstance(value, (int, float)) and int(value) in uc.id_dict: + return uc.id_dict[int(value)].description + elif isinstance(value, dict) and value.get("uncertainty type") in uc.id_dict: + return uc[value["uncertainty type"]].description + return uc[0].description def createEditor(self, parent, option, index): """Simply use the wizard for updating uncertainties. Send a signal.""" - if hasattr(self.parent(), "modify_uncertainty_action"): - self.parent().modify_uncertainty_action.trigger() - elif hasattr(index.internalPointer(), "exchange"): - item = index.internalPointer() - actions.ExchangeUncertaintyModify.run([item.exchange]) - elif index.internalPointer()["_impact_category_name"] is not None: - item = index.internalPointer() - actions.CFUncertaintyModify.run( + from activity_browser import app + + item = index.internalPointer() + item_name = item.__class__.__name__ + + if item_name == "ParametersItem" or item_name == "ProjectParametersItem": + app.actions.ParameterUncertaintyModify.run(item["_parameter"].to_peewee_model()) + elif item_name == "ExchangesItem": + app.actions.ExchangeUncertaintyModify.run([item.exchange]) + elif item_name == "CharacterizationFactorsItem": + app.actions.CFUncertaintyModify.run( item["_impact_category_name"], [(item["_id"], item["_cf"]),] ) + elif isinstance(index.data(), dict): + return UncertaintyDialog(parent=app.main_window, initial=index.data()) + + def setEditorData(self, editor, index: QtCore.QModelIndex): + pass - def setEditorData(self, editor: QtWidgets.QComboBox, index: QtCore.QModelIndex): - """Simply use the wizard for updating uncertainties.""" + def updateEditorGeometry(self, editor, option, index): pass def setModelData( self, - editor: QtWidgets.QComboBox, + editor: UncertaintyDialog, model: QtCore.QAbstractItemModel, index: QtCore.QModelIndex, ): """Read the current text and look up the actual ID of that uncertainty type.""" - uc_id = self.choices.get(editor.currentText(), 0) - model.setData(index, uc_id, QtCore.Qt.EditRole) + if not editor.result() == QtWidgets.QDialog.Accepted: + return + + model.setData(index, editor.result_dict, QtCore.Qt.EditRole) diff --git a/activity_browser/ui/dialogs/README.md b/activity_browser/ui/dialogs/README.md new file mode 100644 index 000000000..ae8cfbd87 --- /dev/null +++ b/activity_browser/ui/dialogs/README.md @@ -0,0 +1,269 @@ +# dialogs + +UI dialog windows for various user interactions. + +## Overview + +This directory contains dialog windows used throughout Activity Browser for user interactions such as configuration, data entry, item selection, and information display. + +## Dialog Categories + +### Input Dialogs +Collect information from users: +- Text input dialogs +- Numeric value entry +- Form-based data entry +- Multi-field configuration + +### Selection Dialogs +Allow users to choose items: +- Activity selection +- Database selection +- Method selection +- File/directory choosers +- List item selection + +### Configuration Dialogs +Manage settings and preferences: +- Application settings +- Project settings +- Database properties +- Import/export configuration +- Plugin configuration + +### Information Dialogs +Display information to users: +- About dialog +- Progress dialogs +- Status messages +- Error and warning dialogs +- Help and documentation + +### Confirmation Dialogs +Request user confirmation: +- Delete confirmations +- Overwrite warnings +- Action confirmations +- Discard changes prompts + +## Common Dialog Types + +### QDialog-based +Standard modal dialogs: +```python +from qtpy.QtWidgets import QDialog + +class MyDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() + + def accept(self): + if self.validate(): + # Process and close + super().accept() +``` + +### QMessageBox-based +Simple message dialogs: +```python +from qtpy.QtWidgets import QMessageBox + +result = QMessageBox.question( + parent, + "Confirm Delete", + "Are you sure you want to delete this item?", + QMessageBox.Yes | QMessageBox.No +) +``` + +### QFileDialog-based +File and directory selection: +```python +from qtpy.QtWidgets import QFileDialog + +filepath = QFileDialog.getOpenFileName( + parent, + "Select File", + "", + "Excel files (*.xlsx)" +) +``` + +## Dialog Features + +### Modal vs. Modeless +- **Modal** - Blocks parent window until closed (most common) +- **Modeless** - Allows interaction with parent (for utilities) + +### Button Boxes +Standard button configurations: +```python +from qtpy.QtWidgets import QDialogButtonBox + +buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel +) +buttons.accepted.connect(self.accept) +buttons.rejected.connect(self.reject) +``` + +### Validation +Validate before accepting: +```python +def accept(self): + if not self.name_input.text(): + QMessageBox.warning(self, "Error", "Name is required") + return + super().accept() +``` + +### Progress Indication +Show progress for long operations: +```python +from qtpy.QtWidgets import QProgressDialog + +progress = QProgressDialog("Processing...", "Cancel", 0, 100, parent) +progress.setWindowModality(Qt.WindowModal) +progress.setValue(50) +``` + +## Usage Patterns + +### Simple Confirmation +```python +from qtpy.QtWidgets import QMessageBox + +reply = QMessageBox.question( + self, + "Confirm", + "Delete this database?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No # Default button +) + +if reply == QMessageBox.Yes: + # Perform deletion + pass +``` + +### Custom Dialog +```python +class MyDialog(QDialog): + def __init__(self, data, parent=None): + super().__init__(parent) + self.data = data + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + + # Add widgets + self.name_edit = QLineEdit() + layout.addWidget(QLabel("Name:")) + layout.addWidget(self.name_edit) + + # Add buttons + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def get_result(self): + """Return dialog result.""" + return self.name_edit.text() +``` + +### Using Custom Dialog +```python +dialog = MyDialog(data, parent=self) +if dialog.exec_() == QDialog.Accepted: + result = dialog.get_result() + # Use result +``` + +## Development Guidelines + +When creating dialogs: + +1. **Inherit from QDialog** - Use Qt's base dialog class +2. **Set parent** - Pass parent widget for proper hierarchy +3. **Provide clear title** - Set window title with setWindowTitle() +4. **Use button boxes** - Standard OK/Cancel buttons +5. **Validate input** - Check data in accept() method +6. **Return results** - Provide method to get dialog results +7. **Handle cancellation** - Clean up if user cancels +8. **Size appropriately** - Fit content, but not too large +9. **Be modal when needed** - Block parent for critical choices +10. **Show progress** - Use QProgressDialog for long operations + +## Threading in Dialogs + +Long operations should use worker threads: + +```python +from activity_browser.ui.core.threading import ABThread + +class MyDialog(QDialog): + def accept(self): + # Show progress + self.progress = QProgressDialog("Processing...", None, 0, 0, self) + self.progress.show() + + # Run in background + worker = ABThread(self.process_data) + worker.finished.connect(self.on_complete) + worker.start() + + def on_complete(self): + self.progress.close() + super().accept() +``` + +## Signal Integration + +Dialogs should emit signals for application updates: + +```python +from activity_browser import app + +class MyDialog(QDialog): + def accept(self): + # Save data + self.save_changes() + + # Notify application + app.signals.data_changed.emit() + + super().accept() +``` + +## Accessibility + +Make dialogs accessible: +- Clear focus order (tab navigation) +- Keyboard shortcuts for buttons +- Screen reader compatible labels +- Escape key to cancel +- Enter key to accept (when safe) + +## Testing + +Test dialogs thoroughly: +```python +def test_my_dialog(qtbot): + dialog = MyDialog() + qtbot.addWidget(dialog) + + # Test initial state + assert dialog.name_edit.text() == "" + + # Simulate user input + qtbot.keyClicks(dialog.name_edit, "Test Name") + + # Test validation + dialog.accept() + assert dialog.result() == QDialog.Accepted +``` diff --git a/activity_browser/ui/dialogs/__init__.py b/activity_browser/ui/dialogs/__init__.py new file mode 100644 index 000000000..bb65f2768 --- /dev/null +++ b/activity_browser/ui/dialogs/__init__.py @@ -0,0 +1,4 @@ +from .list_edit_dialog import ABListEditDialog +from .progress_dialog import ABProgressDialog +from .uncertainty_dialog import UncertaintyDialog + diff --git a/activity_browser/ui/dialogs/list_edit_dialog.py b/activity_browser/ui/dialogs/list_edit_dialog.py new file mode 100644 index 000000000..791a4ca98 --- /dev/null +++ b/activity_browser/ui/dialogs/list_edit_dialog.py @@ -0,0 +1,363 @@ +from qtpy import QtCore, QtGui, QtWidgets +from activity_browser.ui.icons import qicons + + +class DragHandleDelegate(QtWidgets.QStyledItemDelegate): + """Custom delegate that paints a drag handle icon on the left of each row. + + This delegate adds a visual affordance (grip icon) to indicate that rows + can be reordered via drag-and-drop. The icon is painted in the left margin + of each list item. + """ + + def paint(self, painter, option, index): + """Paint the item with a drag handle icon on the left side. + + Parameters + ---------- + painter : QtGui.QPainter + The painter to use for rendering. + option : QtWidgets.QStyleOptionViewItem + Style options for the item. + index : QtCore.QModelIndex + The model index of the item to paint. + """ + super().paint(painter, option, index) + + # Draw drag handle icon on the left + icon_size = 16 + icon_margin = 4 + icon_rect = QtCore.QRect( + option.rect.left() + icon_margin, + option.rect.top() + (option.rect.height() - icon_size) // 2, + icon_size, + icon_size + ) + + # Use a grip icon if available, otherwise use a simple visual indicator + if hasattr(qicons, 'drag_indicator'): + qicons.drag_indicator.paint(painter, icon_rect) + else: + # Fallback: draw three horizontal lines as a grip indicator + painter.save() + painter.setPen(QtGui.QPen(option.palette.mid().color(), 2)) + y_center = icon_rect.center().y() + x_left = icon_rect.left() + 2 + x_right = icon_rect.right() - 2 + for offset in [-4, 0, 4]: + y = y_center + offset + painter.drawLine(x_left, y, x_right, y) + painter.restore() + + +class ABListEditDialog(QtWidgets.QDialog): + """ + A dialog for editing a list or tuple of strings with drag-and-drop reordering. + + Parameters + ---------- + data : iterable of str + Initial values to populate the list. + title : str, optional + Window title for the dialog. Default is "Edit List/Tuple". + parent : QtWidgets.QWidget, optional + Parent widget for the dialog. + + Examples + -------- + >>> dialog = ABListEditDialog(["item1", "item2"], title="Edit Items") + >>> if dialog.exec_() == QtWidgets.QDialog.Accepted: + ... updated_items = dialog.get_data() + """ + + def __init__(self, data, title="Edit List/Tuple", parent=None): + super().__init__(parent) + self.setWindowTitle(title) + self.resize(420, 320) + + layout = QtWidgets.QVBoxLayout(self) + + # List widget + self.list = QtWidgets.QListWidget(self) + self.list.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + self.list.setEditTriggers( + QtWidgets.QAbstractItemView.DoubleClicked | QtWidgets.QAbstractItemView.EditKeyPressed + ) + # Enable intuitive drag-and-drop reordering + self.list.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove) + self.list.setDefaultDropAction(QtCore.Qt.MoveAction) + self.list.setAlternatingRowColors(True) + self.list.setStyleSheet( + """ + QListWidget { alternate-background-color: palette(alternate-base); } + QListWidget::item { padding: 6px 28px 6px 28px; } + QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); } + """ + ) + # Set custom delegate to draw drag handles + self.list.setItemDelegate(DragHandleDelegate(self.list)) + + # OK/Cancel + self.button_box = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + + # Assemble layout + layout.addWidget(self.list) + layout.addWidget(self.button_box) + self.setLayout(layout) + + # Signals + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.reject) + self.list.itemChanged.connect(self.on_item_changed) + self.list.itemSelectionChanged.connect(self._on_selection_changed) + # Reposition inline buttons on scroll/resize/content changes + self.list.verticalScrollBar().valueChanged.connect(self._position_inline_buttons) + self.list.horizontalScrollBar().valueChanged.connect(self._position_inline_buttons) + self.list.viewport().installEventFilter(self) + + # Populate from provided data + self.load_data(data) + self._create_inline_buttons() + self._position_inline_buttons() + + # ---------- Data ---------- + def load_data(self, data): + """Load data into the list widget. + + Populates the list with the provided values. If no data is provided, + adds a single empty row to guide the user. + + Parameters + ---------- + data : iterable of str + Values to populate the list with. + """ + has_any = False + for value in data: + self._append_item(str(value)) + has_any = True + if not has_any: + # Provide a single empty row to guide the user + self._append_item("") + + def get_data(self, as_tuple=False): + """Retrieve the current list data, excluding empty rows. + + Parameters + ---------- + as_tuple : bool, optional + If True, return data as a tuple instead of a list. Default is False. + + Returns + ------- + list or tuple of str + Non-empty string values from the list, in current display order. + """ + values = [] + for row in range(self.list.count()): + item = self.list.item(row) + if item: + text = item.text().strip() + if text: + values.append(text) + return tuple(values) if as_tuple else values + + # ---------- Item helpers ---------- + def _append_item(self, text=""): + """Create and append a new editable, draggable item to the list. + + Parameters + ---------- + text : str, optional + Initial text content for the item. Default is empty string. + """ + item = QtWidgets.QListWidgetItem(text) + # editable + enabled + selectable + draggable + item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsDragEnabled) + self.list.addItem(item) + + def add_item(self): + """Add a new empty item and immediately start editing it. + + This method is connected to the inline add button. It creates a new row, + selects it, and opens it for editing, then repositions the floating buttons. + """ + self._append_item("") + # Select and start editing the newly added row + item = self.list.item(self.list.count() - 1) + self.list.setCurrentItem(item) + self.list.editItem(item) + self._position_inline_buttons() + + def remove_selected(self): + """Remove the currently selected item from the list. + + This method is connected to the inline remove button. After removal, + it ensures at least one empty row remains and adjusts the selection + to the next appropriate row. + """ + row = self._current_row() + if row is None: + return + self.list.takeItem(row) + # keep at least one empty row for guidance + if self.list.count() == 0: + self._append_item("") + # adjust selection + new_row = min(row, self.list.count() - 1) + if new_row >= 0: + self.list.setCurrentRow(new_row) + self._position_inline_buttons() + + # Drag-and-drop handles reordering; no explicit move buttons + + def on_item_changed(self, item: QtWidgets.QListWidgetItem): + """Handle item text changes by normalizing whitespace. + + Connected to the list widget's itemChanged signal. Collapses multiple + consecutive spaces into a single space to maintain clean data. + + Parameters + ---------- + item : QtWidgets.QListWidgetItem + The item that was changed. + """ + # No placeholder logic. Just normalize whitespace. + if item is None: + return + text = item.text() + if text is None: + return + # Collapse accidental multiple spaces + norm = " ".join(text.split()) + if norm != text: + # block signals to avoid recursion + self.list.blockSignals(True) + item.setText(norm) + self.list.blockSignals(False) + + # ---------- UI helpers ---------- + def _current_row(self): + """Get the index of the currently selected row. + + Returns + ------- + int or None + Row index (0-based) of the selected item, or None if nothing is selected. + """ + indexes = self.list.selectedIndexes() + if not indexes: + return None + return indexes[0].row() + + # ---------- Inline buttons ---------- + def _create_inline_buttons(self): + """Create floating icon buttons for add and remove operations. + + Creates two QToolButton instances: + - Add button: positioned at bottom-right corner of the list viewport + - Remove button: positioned inline with the currently selected row + + Both buttons use absolute positioning and are parented to the viewport + to float over the list content. + """ + # Add button at bottom-right + self.inline_add_btn = QtWidgets.QToolButton(self.list.viewport()) + self.inline_add_btn.setIcon(qicons.add) + self.inline_add_btn.setAutoRaise(True) + self.inline_add_btn.setToolTip("Add row") + self.inline_add_btn.clicked.connect(self.add_item) + + # Remove button aligned with selected row + self.inline_remove_btn = QtWidgets.QToolButton(self.list.viewport()) + self.inline_remove_btn.setIcon(qicons.delete) + self.inline_remove_btn.setAutoRaise(True) + self.inline_remove_btn.setToolTip("Remove selected row") + self.inline_remove_btn.clicked.connect(self.remove_selected) + self.inline_remove_btn.hide() + + def _on_selection_changed(self): + """Handle selection changes by repositioning inline buttons. + + Connected to the list widget's itemSelectionChanged signal. Ensures + the remove button follows the selected row. + """ + self._position_inline_buttons() + + def eventFilter(self, obj, event): + """Monitor viewport events to reposition floating buttons when needed. + + Watches for resize, update, and paint events on the list viewport, + deferring button repositioning until after layout updates complete. + + Parameters + ---------- + obj : QtCore.QObject + The object being monitored (should be self.list.viewport()). + event : QtCore.QEvent + The event that occurred. + + Returns + ------- + bool + Result from the parent event filter. + """ + if obj is self.list.viewport(): + if event.type() in (QtCore.QEvent.Resize, QtCore.QEvent.UpdateRequest, QtCore.QEvent.Paint): + # Defer reposition slightly to after layout updates + QtCore.QTimer.singleShot(0, self._position_inline_buttons) + return super().eventFilter(obj, event) + + def _position_inline_buttons(self): + """Calculate and apply absolute positions for floating buttons. + + Positions the add button at the bottom-right corner of the viewport, + and the remove button inline with the currently selected row (if any). + The remove button is only shown if it would be visible within the viewport. + + This method is called on: + - Selection changes + - Scroll events + - Viewport resize/paint/update events + - After adding or removing items + """ + if not hasattr(self, "inline_add_btn") or not hasattr(self, "inline_remove_btn"): + return + + # Position add button at bottom-right corner + viewport_rect = self.list.viewport().rect() + add_w = self.inline_add_btn.sizeHint().width() + add_h = self.inline_add_btn.sizeHint().height() + add_x = viewport_rect.right() - add_w - 6 + add_y = viewport_rect.bottom() - add_h - 6 + self.inline_add_btn.move(add_x, add_y) + self.inline_add_btn.show() + + # Position remove button aligned with selected row + row = self._current_row() + if row is None: + self.inline_remove_btn.hide() + return + item = self.list.item(row) + if item is None: + self.inline_remove_btn.hide() + return + rect = self.list.visualItemRect(item) + if not rect.isValid() or rect.height() <= 0: + self.inline_remove_btn.hide() + return + # Position inside the item's rect at right side with small margin + btn_w = self.inline_remove_btn.sizeHint().width() + btn_h = self.inline_remove_btn.sizeHint().height() + x = rect.right() - btn_w - 6 + y = rect.top() + (rect.height() - btn_h) // 2 + self.inline_remove_btn.move(x, y) + # Only show if fully or partially visible within viewport + if viewport_rect.intersects(QtCore.QRect(x, y, btn_w, btn_h)): + self.inline_remove_btn.show() + else: + self.inline_remove_btn.hide() + + diff --git a/activity_browser/ui/dialogs/progress_dialog.py b/activity_browser/ui/dialogs/progress_dialog.py new file mode 100644 index 000000000..5dec1df83 --- /dev/null +++ b/activity_browser/ui/dialogs/progress_dialog.py @@ -0,0 +1,26 @@ +from qtpy.QtWidgets import QProgressDialog + +from activity_browser.mod.tqdm import qt_tqdm +from activity_browser.mod.pyprind import qt_pyprind + + +class ABProgressDialog(QProgressDialog): + + @classmethod + def get_connected_dialog(cls, title: str) -> "ABProgressDialog": + from activity_browser.app import application + + dialog = cls(application.main_window) + dialog.setWindowTitle(title) + dialog.setLabelText("Initializing") + dialog.setAutoReset(False) + dialog.setCancelButton(None) + + qt_tqdm.updated.connect(dialog._receive_update) + qt_pyprind.updated.connect(dialog._receive_update) + + return dialog + + def _receive_update(self, title: str, value: int): + self.setLabelText(title) + self.setValue(value) diff --git a/activity_browser/ui/dialogs/uncertainty_dialog.py b/activity_browser/ui/dialogs/uncertainty_dialog.py new file mode 100644 index 000000000..bf0eaa434 --- /dev/null +++ b/activity_browser/ui/dialogs/uncertainty_dialog.py @@ -0,0 +1,483 @@ +from __future__ import annotations + +from loguru import logger +from typing import Optional, Tuple + +import numpy as np +import seaborn as sns + +from qtpy import QtCore, QtGui, QtWidgets +import stats_arrays as sa + +from activity_browser.ui.widgets import ABPlot + + + + +EMPTY_UNCERTAINTY = { + "uncertainty type": sa.UndefinedUncertainty.id, + "loc": np.NaN, + "scale": np.NaN, + "shape": np.NaN, + "minimum": np.NaN, + "maximum": np.NaN, + "negative": False, +} + + +class UncertaintyDialog(QtWidgets.QDialog): + """Single-step dialog for defining a stats_arrays uncertainty. + + Mirrors the behavior of the UncertaintyWizard type page but returns a + stats_arrays structured array on accept. + + Usage: + ok, array = UncertaintyDialog.get_uncertainty(parent, initial=dict(...)) + if ok: + # array is a numpy structured array compatible with stats_arrays + """ + + def __init__(self, parent=None, initial: Optional[dict] = None): + super().__init__(parent) + self.setWindowTitle("Set Uncertainty") + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + # State + self.dist = None + self.result_array = None # Filled on accept + self.result_dict = None # Filled on accept + self.previous_dist_id: Optional[int] = None + self.mean_is_calculated = { + sa.TriangularUncertainty.id, + sa.UniformUncertainty.id, + sa.DiscreteUniform.id, + sa.BetaUncertainty.id, + } + + # Top: distribution selection + box1 = QtWidgets.QGroupBox("Select the uncertainty distribution") + self.distribution = QtWidgets.QComboBox(box1) + self.distribution.addItems([ud.description for ud in sa.uncertainty_choices]) + self.distribution.currentIndexChanged.connect(self._on_distribution_changed) + + header_layout = QtWidgets.QGridLayout() + header_layout.addWidget(QtWidgets.QLabel("Distribution:"), 0, 0) + header_layout.addWidget(self.distribution, 0, 1) + box1.setLayout(header_layout) + + # Middle: parameters + self.fields_box = QtWidgets.QGroupBox("Fill out required parameters") + self.locale = QtCore.QLocale( + QtCore.QLocale.English, QtCore.QLocale.UnitedStates + ) + self.locale.setNumberOptions(QtCore.QLocale.RejectGroupSeparator) + self.validator = QtGui.QDoubleValidator() + self.validator.setLocale(self.locale) + + # loc/mean + self.loc_label = QtWidgets.QLabel("Loc:") + self.loc = QtWidgets.QLineEdit() + self.loc.setValidator(self.validator) + self.loc.textEdited.connect(self._sync_mean_from_loc) + self.loc.textEdited.connect(self._check_negative) + self.loc.textEdited.connect(self._generate_plot) + + self.mean_label = QtWidgets.QLabel("Mean:") + self.mean = QtWidgets.QLineEdit() + self.mean.setValidator(self.validator) + self.mean.textEdited.connect(self._sync_loc_from_mean) + self.mean.textEdited.connect(self._check_negative) + self.mean.textEdited.connect(self._generate_plot) + + # Calculated mean (read-only) for some dists + self.calc_mean_label = QtWidgets.QLabel("Mean:") + self.calc_mean = QtWidgets.QLineEdit("nan") + self.calc_mean.setDisabled(True) + + # Other parameters + self.scale_label = QtWidgets.QLabel("Sigma/scale:") + self.scale = QtWidgets.QLineEdit() + self.scale.setValidator(self.validator) + self.scale.textEdited.connect(self._generate_plot) + + self.shape_label = QtWidgets.QLabel("Shape:") + self.shape = QtWidgets.QLineEdit() + self.shape.setValidator(self.validator) + self.shape.textEdited.connect(self._generate_plot) + + self.min_label = QtWidgets.QLabel("Minimum:") + self.minimum = QtWidgets.QLineEdit() + self.minimum.setValidator(self.validator) + self.minimum.textEdited.connect(self._generate_plot) + + self.max_label = QtWidgets.QLabel("Maximum:") + self.maximum = QtWidgets.QLineEdit() + self.maximum.setValidator(self.validator) + self.maximum.textEdited.connect(self._generate_plot) + + # Hidden flag for negative mean on lognormal + self.negative = QtWidgets.QRadioButton(self) + self.negative.setChecked(False) + self.negative.setHidden(True) + + params_layout = QtWidgets.QGridLayout() + # row 0: read-only calculated mean (will be hidden for most dists) + params_layout.addWidget(self.calc_mean_label, 0, 0) + params_layout.addWidget(self.calc_mean, 0, 1) + # row 1: loc/mean pair + params_layout.addWidget(self.loc_label, 1, 0) + params_layout.addWidget(self.loc, 1, 1) + params_layout.addWidget(self.mean_label, 1, 3) + params_layout.addWidget(self.mean, 1, 4) + # row 2+: other params + params_layout.addWidget(self.scale_label, 2, 0) + params_layout.addWidget(self.scale, 2, 1) + params_layout.addWidget(self.shape_label, 3, 0) + params_layout.addWidget(self.shape, 3, 1) + params_layout.addWidget(self.min_label, 4, 0) + params_layout.addWidget(self.minimum, 4, 1) + params_layout.addWidget(self.max_label, 5, 0) + params_layout.addWidget(self.maximum, 5, 1) + self.fields_box.setLayout(params_layout) + + # Bottom: plot + self.plot = SimpleDistributionPlot(self) + + # Buttons + self.buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + self.buttons.accepted.connect(self._on_accept) + self.buttons.rejected.connect(self.reject) + + # Layout + layout = QtWidgets.QVBoxLayout() + layout.addWidget(box1) + layout.addWidget(self.fields_box) + layout.addWidget(self.plot) + layout.addWidget(self.buttons) + self.setLayout(layout) + + # Initialize values (defaults or provided initial) + self._apply_initial(initial or {}) + self._on_distribution_changed(self.distribution.currentIndex()) + self._sync_mean_from_loc() + self._generate_plot() + + # ---------- Public API ---------- + @staticmethod + def get_uncertainty_array( + parent=None, initial: Optional[dict] = None + ) -> Tuple[bool, Optional[np.ndarray]]: + dlg = UncertaintyDialog(parent, initial=initial) + ok = dlg.exec_() == QtWidgets.QDialog.Accepted + return ok, dlg.result_array if ok else None + + @staticmethod + def get_uncertainty_dict( + parent=None, initial: Optional[dict] = None + ) -> Tuple[bool, Optional[dict]]: + dlg = UncertaintyDialog(parent, initial=initial) + ok = dlg.exec_() == QtWidgets.QDialog.Accepted + return ok, dlg.result_dict if ok else None + + # ---------- Internal helpers ---------- + def _apply_initial(self, initial: dict) -> None: + # Use EMPTY_UNCERTAINTY defaults, overridden by initial + data = {k: v for k, v in EMPTY_UNCERTAINTY.items()} + data.update(initial or {}) + # Distribution + try: + uc_type = int(data.get("uncertainty type", 0)) + except Exception: + uc_type = 0 + self.distribution.setCurrentIndex(uc_type) + # Fields (string form for QLineEdit) + def to_str(val): + return "nan" if val is None or (isinstance(val, float) and np.isnan(val)) else str(val) + + self.loc.setText(to_str(data.get("loc", np.nan))) + self.scale.setText(to_str(data.get("scale", np.nan))) + self.shape.setText(to_str(data.get("shape", np.nan))) + self.minimum.setText(to_str(data.get("minimum", np.nan))) + self.maximum.setText(to_str(data.get("maximum", np.nan))) + self._check_negative() + + @property + def _distribution_loc_label(self) -> str: + if self.dist.id == sa.LognormalUncertainty.id: + return "Loc (ln(mean)):" + elif self.dist.id == sa.TriangularUncertainty.id: + return "Mode:" + elif self.dist.id == sa.BetaUncertainty.id: + return "Loc / alpha:" + elif self.dist.id in {sa.GammaUncertainty.id, sa.WeibullUncertainty.id}: + return "Loc / offset:" + else: + return "Mean:" + + def _hide_params(self, *params, hide: bool = True) -> None: + if "loc" in params: + self.loc_label.setHidden(hide) + self.loc.setHidden(hide) + if "scale" in params: + self.scale_label.setHidden(hide) + self.scale.setHidden(hide) + if "shape" in params: + self.shape_label.setHidden(hide) + self.shape.setHidden(hide) + if "min" in params: + self.min_label.setHidden(hide) + self.minimum.setHidden(hide) + if "max" in params: + self.max_label.setHidden(hide) + self.maximum.setHidden(hide) + + def _on_distribution_changed(self, index: int) -> None: + self.dist = sa.uncertainty_choices[index] + + # Show/hide fields per distribution (mirror wizard) + if self.dist.id in {0, 1}: # Undefined / NoUncertainty + self._hide_params("loc", "scale", "shape", "min", "max") + elif self.dist.id in {2, 3}: # Normal / Lognormal + self._hide_params("shape", "min", "max") + self._hide_params("loc", "scale", hide=False) + elif self.dist.id in {4, 7}: # Uniform / DiscreteUniform + self._hide_params("loc", "scale", "shape") + self._hide_params("min", "max", hide=False) + elif self.dist.id in {5, 6}: # Triangular / Bernoulli-like (min/max/loc) + self._hide_params("scale", "shape") + self._hide_params("loc", "min", "max", hide=False) + elif self.dist.id in {8, 9, 10, 11, 12}: # Other 3-param + self._hide_params("min", "max") + self._hide_params("loc", "scale", "shape", hide=False) + + # Special handling (lognormal and calculated mean label) + if self.dist.id == sa.LognormalUncertainty.id: + self.mean.setHidden(False) + self.mean_label.setHidden(False) + # Convert existing loc to log-space if coming from non-lognormal + if self.previous_dist_id is not None and self.previous_dist_id != sa.LognormalUncertainty.id: + self._extract_lognormal_loc_from_mean() + self._sync_mean_from_loc() + else: + self.mean.setHidden(True) + self.mean_label.setHidden(True) + # If switching away from lognormal, set loc to linear amount if mean present + if self.previous_dist_id == sa.LognormalUncertainty.id: + try: + mean_val = float(self.mean.text()) if self.mean.text() else np.nan + if not np.isnan(mean_val): + self.loc.setText(str(mean_val)) + except Exception: + pass + + # Calculated mean visibility + show_calc = self.dist.id in self.mean_is_calculated + self.calc_mean_label.setHidden(not show_calc) + self.calc_mean.setHidden(not show_calc) + + # Update labels + self.loc_label.setText(self._distribution_loc_label) + self.previous_dist_id = self.dist.id + self.fields_box.updateGeometry() + + # Update plot and OK state + self._generate_plot() + self._update_ok_state() + + def _extract_lognormal_loc_from_mean(self) -> None: + """Set loc to ln(mean) when switching to lognormal, if mean is known.""" + try: + mtxt = self.mean.text().strip() + if not mtxt: + return + val = float(mtxt) + if val == 0: + self.loc.setText("nan") + else: + val = -1 * val if val < 0 else val + self.loc.setText(str(np.log(val))) + except Exception: + self.loc.setText("nan") + + def _sync_mean_from_loc(self) -> None: + if not self.loc.text(): + return + try: + self.mean.setText(str(np.exp(float(self.loc.text())))) + except Exception: + self.mean.setText("nan") + self._update_ok_state() + + def _sync_loc_from_mean(self) -> None: + if not self.mean.hasAcceptableInput(): + self.loc.setText("nan") + self._update_ok_state() + return + try: + val = float(self.mean.text()) if self.mean.text() else float("nan") + except Exception: + val = float("nan") + if np.isnan(val) or val == 0: + self.loc.setText("nan") + else: + val = -1 * val if val < 0 else val + self.loc.setText(str(np.log(val))) + self._update_ok_state() + + def _check_negative(self) -> None: + # Special case for lognormal negative mean + try: + if not self.mean.hasAcceptableInput(): + return + val = float(self.mean.text()) if self.mean.text() else float("nan") + except Exception: + val = float("nan") + self.negative.setChecked(bool(not np.isnan(val) and val < 0)) + + def _standard_dist_fields(self, dist_id: int) -> list: + if dist_id in {2, 3}: + return ["loc", "scale"] + elif dist_id in {4, 7}: + return ["minimum", "maximum"] + elif dist_id in {5, 6}: + return ["loc", "minimum", "maximum"] + elif dist_id in {8, 9, 10, 11, 12}: + return ["loc", "scale", "shape"] + else: + return [] + + @property + def _uncertainty_info(self) -> dict: + data = {k: v for k, v in EMPTY_UNCERTAINTY.items()} + data["uncertainty type"] = self.distribution.currentIndex() + data["negative"] = bool(self.negative.isChecked()) + # Pull values from widgets + def as_float(txt: str) -> float: + try: + val = float(txt) + return val + except Exception: + return float("nan") + + for field in self._standard_dist_fields(data["uncertainty type"]): + widget = { + "loc": self.loc, + "scale": self.scale, + "shape": self.shape, + "minimum": self.minimum, + "maximum": self.maximum, + }[field] + data[field] = as_float(widget.text()) + return data + + def _completed_active_fields(self) -> bool: + # Mirror wizard validations + dist_id = self.dist.id + def ok_lineedit(le: QtWidgets.QLineEdit) -> bool: + return bool(le.hasAcceptableInput() and le.text()) + + if dist_id in {0, 1}: + return True + elif dist_id in {2, 3}: + return ok_lineedit(self.loc) and ok_lineedit(self.scale) + elif dist_id in {4, 7}: + return ok_lineedit(self.minimum) and ok_lineedit(self.maximum) + elif dist_id in {5, 6}: + if not (ok_lineedit(self.minimum) and ok_lineedit(self.maximum) and ok_lineedit(self.loc)): + return False + try: + return float(self.minimum.text()) < float(self.loc.text()) < float(self.maximum.text()) + except Exception: + return False + elif dist_id in {8, 9, 10, 11, 12}: + return ok_lineedit(self.scale) and ok_lineedit(self.shape) and ok_lineedit(self.loc) + return False + + def _update_ok_state(self) -> None: + ok_btn = self.buttons.button(QtWidgets.QDialogButtonBox.Ok) + ok_btn.setEnabled(self._completed_active_fields()) + + def _generate_plot(self) -> None: + # Update calculated mean if applicable and render sample + if self.dist is None: + return + complete = self._completed_active_fields() or self.dist.id in {sa.UndefinedUncertainty.id, sa.NoUncertainty.id} + if not complete: + self._update_ok_state() + return + array = self.dist.from_dicts(self._uncertainty_info) + # Calculated mean display for specific distributions + if self.dist.id in self.mean_is_calculated: + try: + calc = self.dist.statistics(array).get("mean") + except TypeError: + # DiscreteUniform workaround + array = self.dist.fix_nan_minimum(array) + calc = (array["maximum"] + array["minimum"]) / 2 + calc = calc.mean() if isinstance(calc, np.ndarray) else calc + self.calc_mean.setText(str(float(calc))) + # Vertical line value + if self.dist.id == sa.LognormalUncertainty.id: + vline = self.dist.statistics(array).get("median") + elif self.dist.id in {sa.UndefinedUncertainty.id, sa.NoUncertainty.id}: + # Best effort: use loc as "mean" placeholder + try: + vline = float(self.loc.text()) if self.loc.text() else np.nan + except Exception: + vline = np.nan + else: + vline = self.dist.statistics(array).get("mean") + # Sample data + data = self.dist.random_variables(array, 1000) + if not np.any(np.isnan(data)): + try: + self.plot.plot(data, vline) + except RuntimeError as e: + logger.error("%s: plotting failed, retry without KDE", e) + try: + sns.histplot(data.T, kde=False, stat="density", ax=self.plot.ax, edgecolor="none") + self.plot.ax.axvline(vline, label="Mean / amount", c="r", ymax=0.98) + self.plot.ax.legend(loc="upper right") + self.plot.canvas.draw() + except Exception: + pass + self._update_ok_state() + + def _on_accept(self) -> None: + try: + self.result_dict = self._uncertainty_info + self.result_array = self.dist.from_dicts(self._uncertainty_info) + except Exception as e: + QtWidgets.QMessageBox.warning( + self, + "Invalid uncertainty", + str(e), + QtWidgets.QMessageBox.Ok, + QtWidgets.QMessageBox.Ok, + ) + return + self.accept() + + +class SimpleDistributionPlot(ABPlot): + def plot(self, data: np.ndarray, mean: float, label: str = "Value"): + self.reset_plot() + try: + sns.histplot(data.T, kde=True, stat="density", ax=self.ax, edgecolor="none") + except RuntimeError as e: + logger.error("%s: Plotting without KDE.", e) + sns.histplot(data.T, kde=False, stat="density", ax=self.ax, edgecolor="none") + self.ax.set_xlabel(label) + self.ax.set_ylabel("Probability density") + # Add vertical line at given mean of x-axis + self.ax.axvline(mean, label="Mean / amount", c="r", ymax=0.98) + self.ax.legend(loc="upper right") + _, height = self.canvas.get_width_height() + self.setMinimumHeight(height / 2) + self.canvas.draw() + + +__all__ = ["UncertaintyDialog"] + diff --git a/activity_browser/ui/icons.py b/activity_browser/ui/icons.py index 011f21576..37d304a91 100644 --- a/activity_browser/ui/icons.py +++ b/activity_browser/ui/icons.py @@ -4,6 +4,7 @@ from qtpy.QtCore import Qt, QSize from qtpy.QtGui import QIcon, QPixmap + PACKAGE_DIR = Path(__file__).resolve().parents[1] @@ -18,95 +19,86 @@ def empty_icon(size: QSize = QSize(32, 32)) -> QIcon: return QIcon(pixmap) -# CURRENTLY UNUSED ICONS - -# Modular LCA (keep until this is reintegrated) -# add_db = create_path('metaprocess', 'add_database.png') -# close_db = create_path('metaprocess', 'close_database.png') -# cut = create_path('metaprocess', 'cut.png') -# debug = create_path('main', 'ladybird.png') -# duplicate = create_path('metaprocess', 'duplicate.png') -# graph_lmp = create_path('metaprocess', 'graph_linkedmetaprocess.png') -# graph_mp = create_path('metaprocess', 'graph_metaprocess.png') -# load_db = create_path('metaprocess', 'open_database.png') -# metaprocess = create_path('metaprocess', 'metaprocess.png') -# new = create_path('metaprocess', 'new_metaprocess.png') -# save_db = create_path('metaprocess', 'save_database.png') -# save_mp = create_path('metaprocess', 'save_metaprocess.png') - -# key = create_path('main', 'key.png') -# search = create_path('main', 'search.png') -# switch = create_path('main', 'switch-state.png') - - -class Icons(object): +icons = dict( # Icons from href="https://www.flaticon.com/ # MAIN - ab = create_path("main", "activitybrowser.png") + ab = create_path("main", "activitybrowser.png"), # arrows - right = create_path("main", "right.png") - left = create_path("main", "left.png") - forward = create_path("main", "forward.png") - backward = create_path("main", "backward.png") + right = create_path("main", "right.png"), + left = create_path("main", "left.png"), + forward = create_path("main", "forward.png"), + backward = create_path("main", "backward.png"), # Simple actions - delete = create_path("context", "delete.png") - clear = create_path("context", "clear.png") - copy = create_path("context", "copy.png") - add = create_path("context", "add.png") - edit = create_path("main", "edit.png") - calculate = create_path("main", "calculate.png") - question = create_path("context", "question.png") - search = create_path("main", "search.png") - filter = create_path("main", "filter.png") - filter_outline = create_path("main", "filter_outline.png") + delete = create_path("context", "delete.png"), + clear = create_path("context", "clear.png"), + copy = create_path("context", "copy.png"), + add = create_path("context", "add.png"), + edit = create_path("main", "edit.png"), + calculate = create_path("main", "calculate.png"), + question = create_path("context", "question.png"), + search = create_path("main", "search.png"), + filter = create_path("main", "filter.png"), + filter_outline = create_path("main", "filter_outline.png"), # database - import_db = create_path("main", "import_database.png") - duplicate_database = create_path("main", "duplicate_database.png") + import_db = create_path("main", "import_database.png"), + duplicate_database = create_path("main", "duplicate_database.png"), # activity - duplicate_activity = create_path("main", "duplicate_activity.png") - duplicate_to_other_database = create_path("main", "import_database.png") - parameterized = create_path("main", "parameterized.png") + duplicate_activity = create_path("main", "duplicate_activity.png"), + duplicate_to_other_database = create_path("main", "import_database.png"), + parameterized = create_path("main", "parameterized.png"), # windows - graph_explorer = create_path("main", "graph_explorer.png") - issue = create_path("main", "idea.png") - settings = create_path("main", "settings.png") - history = create_path("main", "history.png") - welcome = create_path("main", "welcome.png") - main_window = create_path("main", "home.png") + graph_explorer = create_path("main", "graph_explorer.png"), + issue = create_path("main", "idea.png"), + settings = create_path("main", "settings.png"), + history = create_path("main", "history.png"), + welcome = create_path("main", "welcome.png"), + main_window = create_path("main", "home.png"), # plugins - plugin = create_path("main", "plugin.png") + plugin = create_path("main", "plugin.png"), # nodes - process = create_path("nodes", "process.png") - product = create_path("nodes", "product.png") - waste = create_path("nodes", "waste.png") - processproduct = create_path("nodes", "processproduct.png") - biosphere = create_path("nodes", "biosphere.png") - readonly_process = create_path("nodes", "read-only-process.png") + process = create_path("nodes", "process.png"), + product = create_path("nodes", "product.png"), + waste = create_path("nodes", "waste.png"), + processproduct = create_path("nodes", "processproduct.png"), + biosphere = create_path("nodes", "biosphere.png"), + readonly_process = create_path("nodes", "read-only-process.png"), + + # exchanges + link = create_path("exchanges", "link.png"), + unlink = create_path("exchanges", "unlink.png"), + relink = create_path("exchanges", "relink.png"), # other - superstructure = create_path("main", "superstructure.png") - copy_to_clipboard = create_path("main", "copy_to_clipboard.png") - warning = create_path("context", "warning.png") - critical = create_path("context", "critical.png") - locked = create_path("main", "locked.png") - unlocked = create_path("main", "unlocked.png") - - -class QIcons(Icons): - """Using the Icons class, returns the same attributes, but as QIcon type""" - empty = empty_icon() - - def __getattribute__(self, item): - return QIcon(Icons.__getattribute__(self, item)) - - -icons = Icons() + superstructure = create_path("main", "superstructure.png"), + copy_to_clipboard = create_path("main", "copy_to_clipboard.png"), + warning = create_path("context", "warning.png"), + critical = create_path("context", "critical.png"), + locked = create_path("main", "locked.png"), + unlocked = create_path("main", "unlocked.png"), + star = create_path("main", "star.png"), +) + + +class QIcons: + """Lazily loads QIcon instances only when accessed.""" + def __getattribute__(self, name): + if name == 'empty': + return empty_icon() + elif name in icons: + if name not in _initialized_icons: + _initialized_icons[name] = QIcon(icons[name]) + return _initialized_icons[name] + else: + raise AttributeError(f"QIcons has no icon '{name}'") + +_initialized_icons = {} qicons = QIcons() + diff --git a/activity_browser/ui/widgets/README.md b/activity_browser/ui/widgets/README.md new file mode 100644 index 000000000..cb3bc002e --- /dev/null +++ b/activity_browser/ui/widgets/README.md @@ -0,0 +1,202 @@ +# widgets + +Reusable custom widget components for the Activity Browser interface. + +## Overview + +This directory contains a collection of custom Qt widgets used throughout Activity Browser. These widgets extend Qt's base widgets with application-specific functionality, styling, and behavior. + +## Key Files + +### Abstract Base Classes +- **`abstract_page.py`** - Base class for main content area pages +- **`abstract_pane.py`** - Base class for dock-able side panels + +### Layout and Container Widgets +- **`central.py`** - Central widget that holds the main content area +- **`dock_widget.py`** - Custom dock widget with additional features +- **`tab_widget.py`** - Enhanced tab widget with custom styling + +### Input Widgets +- **`line_edit.py`** - Enhanced single-line text input +- **`text_edit.py`** - Multi-line text editor with additional features +- **`combobox.py`** - Drop-down selection with search and filtering +- **`formula_edit.py`** - Specialized editor for parameter formulas +- **`database_name_edit.py`** - Input widget for database names with validation + +### Display Widgets +- **`label.py`** - Custom labels with additional styling options +- **`tree_view.py`** - Enhanced tree view for hierarchical data +- **`plot.py`** - Plotting widgets for charts and graphs + +### Interactive Widgets +- **`buttons.py`** - Custom button variations (icon buttons, toggle buttons) +- **`button_collapser.py`** - Collapsible sections with expand/collapse buttons +- **`comparison_switch.py`** - Switch between different comparison views +- **`cutoff_menu.py`** - Menu for selecting cutoff thresholds +- **`menu.py`** - Enhanced context and popup menus + +### Utility Widgets +- **`file_selector.py`** - File/directory selection with browse button +- **`drop_overlay.py`** - Visual overlay for drag-and-drop operations +- **`line.py`** - Visual separator lines + +### Wizards +- **`wizard.py`** - Base wizard dialog for multi-step workflows +- **`wizard_page.py`** - Individual pages within wizards + +## Widget Categories + +### Page Widgets (AbstractPage) +Main content pages inherit from `AbstractPage`: +- Consistent toolbar integration +- Signal connection handling +- State management +- Layout conventions + +```python +from activity_browser.ui.widgets import AbstractPage + +class MyPage(AbstractPage): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_ui() +``` + +### Pane Widgets (AbstractPane) +Dock-able panes inherit from `AbstractPane`: +- Dock widget functionality +- Visibility persistence +- Resize handling +- Title bar customization + +```python +from activity_browser.ui.widgets import AbstractPane + +class MyPane(AbstractPane): + def __init__(self, parent=None): + super().__init__(parent) + self.setup_content() +``` + +### Input Widgets +Enhanced input widgets with: +- Validation +- Placeholder text +- Clear buttons +- Auto-completion +- Format enforcement + +### Display Widgets +Specialized display widgets: +- Custom rendering +- Context menus +- Copy/export functionality +- Sorting and filtering + +## Common Patterns + +### Signal Connections +Widgets connect to global signals: +```python +from activity_browser import app + +app.signals.data_changed.connect(self.refresh) +``` + +### Validation +Input widgets validate data: +```python +class MyLineEdit(QLineEdit): + def validate_input(self): + if not self.text().strip(): + self.setStyleSheet("border: 1px solid red") + return False + return True +``` + +### Context Menus +Many widgets provide context menus: +```python +def contextMenuEvent(self, event): + menu = QMenu(self) + menu.addAction("Copy", self.copy_selection) + menu.addAction("Export", self.export_data) + menu.exec_(event.globalPos()) +``` + +## Styling + +Widgets use Qt stylesheets for consistent appearance: + +```python +self.setStyleSheet(""" + QWidget { + background-color: #ffffff; + color: #000000; + } + QPushButton { + border: 1px solid #cccccc; + border-radius: 3px; + padding: 5px; + } +""") +``` + +## Development Guidelines + +When creating custom widgets: + +1. **Inherit from appropriate base class** - Use AbstractPage/AbstractPane when applicable +2. **Emit signals for state changes** - Enable other components to react +3. **Support keyboard navigation** - Implement tab order and shortcuts +4. **Provide context menus** - Right-click actions for common operations +5. **Validate input** - Check data before accepting +6. **Handle errors gracefully** - Show user-friendly error messages +7. **Use consistent styling** - Follow application design patterns +8. **Document public API** - Docstrings for public methods and signals +9. **Make widgets reusable** - Avoid hard-coding application logic +10. **Test widgets independently** - Unit tests for widget behavior + +## Reusability + +Widgets should be: +- **Self-contained** - Minimal external dependencies +- **Configurable** - Properties for customization +- **Composable** - Can be combined into complex UIs +- **Generic** - Not tied to specific data models + +## Accessibility + +Consider accessibility: +- Keyboard navigation +- Screen reader compatibility +- High contrast support +- Focus indicators +- Logical tab order + +## Performance + +Optimize widget performance: +- Lazy loading of data +- Virtual scrolling for large lists +- Efficient repainting +- Debounced event handlers +- Cache computed values + +## Testing + +Widget tests should verify: +- Initial state and defaults +- User interactions (clicks, text entry) +- Signal emission +- Validation logic +- Edge cases and error handling + +Use pytest-qt for testing: +```python +def test_my_widget(qtbot): + widget = MyWidget() + qtbot.addWidget(widget) + # Test widget behavior +``` diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index d92fe1773..8f0434434 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -1,16 +1,14 @@ +from .plot import ABPlot from .abstract_pane import ABAbstractPane from .comparison_switch import SwitchComboBox from .cutoff_menu import CutoffMenu from .line_edit import (ABLineEdit, SignalledComboEdit, SignalledLineEdit, SignalledPlainTextEdit) -from .treeview import ABTreeView -from .item_model import ABItemModel -from .item import ABAbstractItem, ABBranchItem, ABDataItem +from .text_edit import (ABAutoCompleTextEdit, ABTextEdit, MetaDataAutoCompleteTextEdit) from .line import ABHLine, ABVLine from .formula_edit import ABFormulaEdit -from .progress_dialog import ABProgressDialog -from .combobox import ABComboBox +from .combobox import ABComboBox, CheckableComboBox from .button_collapser import ABRadioButtonCollapser from .wizard import ABWizard from .wizard_page import ABWizardPage, ABThreadedWizardPage @@ -18,9 +16,11 @@ from .database_name_edit import DatabaseNameEdit from .dock_widget import ABDockWidget from .label import ABLabel -from .main_window import MainWindow from .central import CentralTabWidget from .menu import ABMenu -from .list_edit_dialog import ABListEditDialog from .drop_overlay import ABDropOverlay -from .database_selection_dialog import ABDatabaseSelectionDialog +from .tree_view import ABTreeView +from .buttons import ABCloseButton, ABMinimizeButton +from .tab_widget import ABTabWidget +from .web_engine_page import ABWebEnginePage +from .abstract_navigator import ABAbstractNavigator, ABAbstractGraph diff --git a/activity_browser/ui/widgets/abstract_navigator.py b/activity_browser/ui/widgets/abstract_navigator.py new file mode 100644 index 000000000..065e8b563 --- /dev/null +++ b/activity_browser/ui/widgets/abstract_navigator.py @@ -0,0 +1,218 @@ +import json +import os +from abc import abstractmethod +from copy import deepcopy +from typing import Type +from loguru import logger + +from qtpy import QtWebChannel, QtWebEngineWidgets, QtWidgets +from qtpy.QtCore import QObject, Qt, QUrl, Signal, Slot + +from activity_browser.ui.icons import qicons +from activity_browser.bwutils import filesystem + +from .web_engine_page import ABWebEnginePage + + +class ABAbstractNavigator(QtWidgets.QWidget): + HELP_TEXT = """ + This is the text shown when the user presses 'help'. + """ + HTML_FILE = "" + + def __init__(self, parent=None, css_file: str = "", *args, **kwargs): + super().__init__(parent) + + # Graph object subclassed from BaseGraph. + self.graph: Type[ABAbstractGraph] + + # Setup JS / Qt interactions + self.bridge = Bridge(self) + self.channel = QtWebChannel.QWebChannel(self) + self.channel.registerObject("bridge", self.bridge) + self.view = QtWebEngineWidgets.QWebEngineView(self) + self.page = ABWebEnginePage(self.view) + self.view.setPage(self.page) + self.view.loadFinished.connect(self.load_finished_handler) + self.view.setContextMenuPolicy(Qt.PreventContextMenu) + self.view.page().setWebChannel(self.channel) + self.url = QUrl.fromLocalFile(self.HTML_FILE) + self.css_file = css_file + + # Various Qt objects + self.label_help = QtWidgets.QLabel(self.HELP_TEXT) + self.button_toggle_help = QtWidgets.QPushButton("Help") + self.button_back = QtWidgets.QPushButton(qicons.backward, "") + self.button_forward = QtWidgets.QPushButton(qicons.forward, "") + self.button_refresh = QtWidgets.QPushButton("Refresh HTML") + self.button_random_activity = QtWidgets.QPushButton("Random Activity") + + def load_finished_handler(self, *args, **kwargs) -> None: + """Executed when webpage has been loaded for the first time or refreshed. + + Can be used to trigger a calculation after the webpage has been + completely loaded. + """ + pass + + @abstractmethod + def connect_signals(self) -> None: + self.button_toggle_help.clicked.connect(self.toggle_help) + self.button_back.clicked.connect(self.go_back) + self.button_forward.clicked.connect(self.go_forward) + self.button_refresh.clicked.connect(self.draw_graph) + self.button_random_activity.clicked.connect(self.random_graph) + + @abstractmethod + def construct_layout(self) -> None: + pass + + def toggle_help(self) -> None: + self.label_help.setVisible(self.label_help.isHidden()) + + def go_forward(self) -> None: + if self.graph.forward(): + self.send_json() + + def go_back(self) -> None: + if self.graph.back(): + self.send_json() + + def send_json(self) -> None: + if self.graph.json_data is None: + return + self.bridge.graph_ready.emit(self.graph.json_data) + css_path = get_static_css_path(self.css_file) + + with open(css_path, "r") as css_file: + css_code = css_file.read() + + style_element = "" + self.bridge.style.emit(style_element) + + def draw_graph(self) -> None: + self.view.load(self.url) + + @abstractmethod + def random_graph(self) -> None: + pass + + +ALL_FILTER = "All Files (*.*)" + + +def savefilepath(default_file_name: str, file_filter: str = ALL_FILTER): + from activity_browser.bwutils import filesystem + import bw2data as bd + + default = default_file_name or "Graph SVG Export" + safe_name = bd.utils.safe_filename(default, add_hash=False) + filepath, _ = QtWidgets.QFileDialog.getSaveFileName( + caption="Choose location to save svg", + dir=os.path.join(filesystem.get_project_path(), safe_name), + filter=file_filter, + ) + return filepath + + +def to_svg(svg): + """Export to .svg format.""" + # TODO: Exported filename + filepath = savefilepath(default_file_name="svg_export", file_filter="SVG (*.svg)") + if filepath: + if not filepath.endswith(".svg"): + filepath += ".svg" + svg_file = open(filepath, "w", encoding="utf-8") + svg_file.write(svg) + svg_file.close() + + +class Bridge(QObject): + graph_ready = Signal(str) + update_graph = Signal(object) + style = Signal(str) + + @Slot(str, name="node_clicked") + def node_clicked(self, click_text: str): + """Is called when a node is clicked in Javascript. + Args: + click_text: string of a serialized json dictionary describing + - the node that was clicked on + - mouse button and additional keys pressed + """ + click_dict = json.loads(click_text) + click_dict["key"] = ( + click_dict["database"], + click_dict["id"], + ) # since JSON does not know tuples + logger.info(f"Click information: {click_dict}") # TODO click_dict needs correcting + self.update_graph.emit(click_dict) + + @Slot(str, name="download_triggered") + def download_triggered(self, svg: str): + """Is called when a node is clicked in Javascript. + Args: + svg: string of svg + """ + to_svg(svg) + + +class ABAbstractGraph(object): + def __init__(self): + self.json_data = None + # stores previous graphs, if any, and enables back/forward buttons + self.stack = [] + # stores graphs that can be returned to after having used the "back" button + self.forward_stack = [] + + def update(self, delete_unstacked: bool = True) -> None: + self.store_previous() + if delete_unstacked: + self.forward_stack = [] + + def forward(self) -> bool: + """Go forward, if previously gone back.""" + if not self.forward_stack: + return False + self.retrieve_future() + self.update(delete_unstacked=False) + return True + + def back(self) -> bool: + """Go back to previous graph, if any.""" + if len(self.stack) <= 1: + return False + self.store_future() + self.update(delete_unstacked=False) + return True + + def store_previous(self) -> None: + """Store the current graph in the""" + self.stack.append((deepcopy(self.json_data))) + + def store_future(self) -> None: + """When going back, store current data in a queue.""" + self.forward_stack.append(self.stack.pop()) + self.json_data = self.stack.pop() + + def retrieve_future(self) -> None: + """Extract the last graph from the queue.""" + self.json_data = self.forward_stack.pop() + + @abstractmethod + def new_graph(self, *args, **kwargs) -> None: + pass + + def save_json_to_file(self, filename: str = "graph_data.json") -> None: + """Writes the current model´s JSON representation to the specifies file.""" + if self.json_data: + filepath = os.path.join(os.path.dirname(__file__), filename) + with open(filepath, "w") as outfile: + json.dump(self.json_data, outfile) + +def get_static_js_path(file_name: str = "") -> str: + return str(filesystem.get_package_path() / "static" / "javascript" / file_name) + + +def get_static_css_path(file_name: str = "") -> str: + return str(filesystem.get_package_path() / "static" / "css" / file_name) \ No newline at end of file diff --git a/activity_browser/ui/widgets/abstract_page.py b/activity_browser/ui/widgets/abstract_page.py new file mode 100644 index 000000000..6e017208a --- /dev/null +++ b/activity_browser/ui/widgets/abstract_page.py @@ -0,0 +1,8 @@ +from qtpy import QtWidgets + + +class ABAbstractPage(QtWidgets.QWidget): + + def toggleViewAction(self, main_window): + """Return the toggle view action for this page.""" + return diff --git a/activity_browser/ui/widgets/buttons.py b/activity_browser/ui/widgets/buttons.py new file mode 100644 index 000000000..a153cfd66 --- /dev/null +++ b/activity_browser/ui/widgets/buttons.py @@ -0,0 +1,63 @@ +from qtpy import QtWidgets, QtCore, QtGui +from qtpy.QtCore import Qt + + +class ABCloseButton(QtWidgets.QWidget): + """Custom close button with hover effect.""" + clicked: QtCore.SignalInstance = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + + + self.label = QtWidgets.QLabel("×", self) + + self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Weight.Light)) + self.label.setAlignment(Qt.AlignCenter) + self.label.setFixedSize(16, 16) + self.label.mousePressEvent = lambda event: self.clicked.emit() + + self.label.setStyleSheet(""" + QLabel { + border-radius: 8px; + background-color: transparent; + } + QLabel:hover { + background-color: rgba(255, 0, 0, 0.5); + } + """) + + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(5, 0, 0, 0) + layout.addWidget(self.label) + self.setLayout(layout) + + +class ABMinimizeButton(QtWidgets.QWidget): + """Custom close button with hover effect.""" + clicked: QtCore.SignalInstance = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + + self.label = QtWidgets.QLabel("-", self) + + self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Weight.Light)) + self.label.setAlignment(Qt.AlignCenter) + self.label.setFixedSize(16, 16) + self.label.mousePressEvent = lambda event: self.clicked.emit() + + self.setStyleSheet(""" + QLabel { + border-radius: 8px; + background-color: transparent; + } + QLabel:hover { + background-color: rgba(42, 157, 244, 0.5); + } + """) + + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(5, 0, 0, 0) + layout.addWidget(self.label) + self.setLayout(layout) diff --git a/activity_browser/ui/widgets/central.py b/activity_browser/ui/widgets/central.py index 726cc09c6..4829b7239 100644 --- a/activity_browser/ui/widgets/central.py +++ b/activity_browser/ui/widgets/central.py @@ -1,14 +1,11 @@ -from logging import getLogger +from loguru import logger from qtpy import QtWidgets -from activity_browser import signals +from .tab_widget import ABTabWidget -log = getLogger(__name__) - - -class CentralTabWidget(QtWidgets.QTabWidget): +class CentralTabWidget(ABTabWidget): """ A custom QTabWidget that manages groups of tabs and their associated pages. @@ -16,17 +13,6 @@ class CentralTabWidget(QtWidgets.QTabWidget): and ensuring that each page has a unique object name. """ - def __init__(self, *args): - """ - Initialize the CentralTabWidget. - - Args: - *args: Positional arguments passed to the parent QTabWidget. - """ - super().__init__(*args) - # Connect to the project changed signal to reset the current index to 0 - signals.project.changed.connect(self.reset) - @property def groups(self): """ @@ -59,6 +45,7 @@ def addToGroup(self, group: str, page: QtWidgets.QWidget): self.addTab(GroupTabWidget(group, self), group) group = self.groups[group] + self.setCurrentWidget(group) # Check if the page already exists in the group page_names = [group.widget(i).objectName() for i in range(group.count())] @@ -68,22 +55,17 @@ def addToGroup(self, group: str, page: QtWidgets.QWidget): page.setWindowTitle(name) # make sure the page has a title page.setParent(group) group.addTab(page, name) + group.setCurrentWidget(page) page.windowTitleChanged.connect(lambda title: group.setTabText(group.indexOf(page), title)) else: # Set the existing page as the current tab index = page_names.index(page.objectName()) group.setCurrentIndex(index) - - # Set the group and page as the current widgets - self.setCurrentWidget(group) - group.setCurrentWidget(page) - - def reset(self): - self.setCurrentIndex(0) + page.deleteLater() # Clean up the newly created page since it already exists -class GroupTabWidget(QtWidgets.QTabWidget): +class GroupTabWidget(ABTabWidget): """ A custom QTabWidget that represents a group of tabs. @@ -100,9 +82,6 @@ def __init__(self, name: str, *args): *args: Additional positional arguments passed to the parent QTabWidget. """ super().__init__(*args) - self.setMovable(True) # Allow tabs to be rearranged. - self.setTabsClosable(True) # Allow tabs to be closed. - self.setDocumentMode(True) # Enable document mode for a more modern appearance. self.setObjectName(name) # Set the object name for the widget. @@ -115,6 +94,7 @@ def connect_signals(self): - Connects the `tabCloseRequested` signal to the `tabClosed` method. - Connects the `project.changed` signal to the `deleteLater` method to clean up the widget. """ + from activity_browser.app import signals self.tabCloseRequested.connect(self.tabClosed) signals.project.changed.connect(self.deleteLater) diff --git a/activity_browser/ui/widgets/cutoff_menu.py b/activity_browser/ui/widgets/cutoff_menu.py index 397d5ae2f..e0f1f5016 100644 --- a/activity_browser/ui/widgets/cutoff_menu.py +++ b/activity_browser/ui/widgets/cutoff_menu.py @@ -9,7 +9,6 @@ from collections import namedtuple from typing import Union -import numpy as np from qtpy import QtCore from qtpy.QtCore import QLocale, Qt, Signal, Slot from qtpy.QtGui import QDoubleValidator, QIntValidator @@ -413,6 +412,7 @@ def log_value(self) -> Union[int, float]: This function converts the 1-100 values and modifies these to 0.001-100 on a logarithmic scale. Rounding is done based on magnitude. """ + import numpy as np # Logarithmic math refresher: # BOP = Base, Outcome Power; @@ -437,6 +437,8 @@ def log_value(self) -> Union[int, float]: @log_value.setter def log_value(self, value: float) -> None: """Modify value from 0.001-100 to 1-100 logarithmically and set slider to value.""" + import numpy as np + value = int(float(value) * np.power(10, 3)) log_val = np.log10(value).round(3) set_val = log_val * 20 diff --git a/activity_browser/ui/widgets/database_name_edit.py b/activity_browser/ui/widgets/database_name_edit.py index 5cc210a4b..0d6aa05c2 100644 --- a/activity_browser/ui/widgets/database_name_edit.py +++ b/activity_browser/ui/widgets/database_name_edit.py @@ -1,7 +1,5 @@ from qtpy import QtWidgets, QtCore -import bw2data as bd - class DatabaseNameEdit(QtWidgets.QWidget): """ @@ -73,5 +71,6 @@ def setText(self, text: str): self.database_name.setText(text) def willOverwrite(self) -> bool: + import bw2data as bd return self.database_name.text() in bd.databases diff --git a/activity_browser/ui/widgets/dock_widget.py b/activity_browser/ui/widgets/dock_widget.py index 1917d89f1..df428393c 100644 --- a/activity_browser/ui/widgets/dock_widget.py +++ b/activity_browser/ui/widgets/dock_widget.py @@ -1,6 +1,8 @@ -from qtpy import QtWidgets, QtCore, QtGui +from qtpy import QtWidgets, QtGui from qtpy.QtCore import Qt +from .buttons import ABCloseButton, ABMinimizeButton + class HideMode: Close = 1 @@ -32,10 +34,10 @@ def setWidget(self, widget): def button(self): if self._hide_mode == HideMode.Close: - button = CloseButton(self) + button = ABCloseButton(self) button.clicked.connect(self.close) else: - button = MinimizeButton(self) + button = ABMinimizeButton(self) button.clicked.connect(self.hide) return button @@ -64,82 +66,3 @@ def set_button(self, button): w.deleteLater() -class CloseButton(QtWidgets.QWidget): - """Custom close button with hover effect.""" - clicked: QtCore.SignalInstance = QtCore.Signal() - - def __init__(self, parent=None): - super().__init__(parent) - - - self.label = QtWidgets.QLabel("×", self) - - self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) - self.label.setAlignment(Qt.AlignCenter) - self.label.setFixedSize(16, 16) - self.label.mousePressEvent = lambda event: self.clicked.emit() - - self.label.setStyleSheet(""" - QLabel { - border-radius: 8px; - background-color: transparent; - } - QLabel:hover { - background-color: rgba(255, 0, 0, 0.5); - } - """) - - layout = QtWidgets.QHBoxLayout() - layout.setContentsMargins(5, 0, 0, 0) - layout.addWidget(self.label) - self.setLayout(layout) - - -class MinimizeButton(QtWidgets.QWidget): - """Custom close button with hover effect.""" - clicked: QtCore.SignalInstance = QtCore.Signal() - - def __init__(self, parent=None): - super().__init__(parent) - - self.label = QtWidgets.QLabel("-", self) - - self.label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) - self.label.setAlignment(Qt.AlignCenter) - self.label.setFixedSize(16, 16) - self.label.mousePressEvent = lambda event: self.clicked.emit() - - self.setStyleSheet(""" - QLabel { - border-radius: 8px; - background-color: transparent; - } - QLabel:hover { - background-color: rgba(42, 157, 244, 0.5); - } - """) - - layout = QtWidgets.QHBoxLayout() - layout.setContentsMargins(5, 0, 0, 0) - layout.addWidget(self.label) - self.setLayout(layout) - - -def mousePressEvent(self, event): - if event.button() == Qt.LeftButton: - self.drag_start_pos = event.pos() - - -def mouseMoveEvent(self, event): - if not self.drag_start_pos: - return - - # Check if mouse moved beyond threshold - if (event.pos() - self.drag_start_pos).manhattanLength() > QtWidgets.QApplication.startDragDistance(): - index = self.tabAt(self.drag_start_pos) - if index >= 0: - startDrag(self, index) - -def startDrag(self, index): - """Start dragging a tab.""" - print("Dragging success") diff --git a/activity_browser/ui/widgets/drop_overlay.py b/activity_browser/ui/widgets/drop_overlay.py index 326b2e2d8..66ab1ce71 100644 --- a/activity_browser/ui/widgets/drop_overlay.py +++ b/activity_browser/ui/widgets/drop_overlay.py @@ -1,24 +1,60 @@ +from typing import Literal + from qtpy import QtWidgets, QtGui from qtpy.QtCore import Qt class ABDropOverlay(QtWidgets.QWidget): - def __init__(self, parent=None): + opacityMap = { + "low": 100, + "medium": 150, + "high": 200, + } + + def __init__(self, parent=None, text="Drop here to create new exchanges"): super().__init__(parent) - self.setAttribute(Qt.WA_TransparentForMouseEvents) - self.setAttribute(Qt.WA_NoSystemBackground) - self.setAttribute(Qt.WA_TranslucentBackground) + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAutoFillBackground(False) self.resize(parent.size()) + self._text = text + self._opacity: Literal["low", "medium", "high"] = "medium" + + def hovering(self) -> bool: + cursor_pos = QtGui.QCursor.pos() + widget_rect = self.rect() + local_pos = self.mapFromGlobal(cursor_pos) + return widget_rect.contains(local_pos) + + def setOpacity(self, level: Literal["low", "medium", "high"]): + if level in self.opacityMap: + self._opacity = level + self.update() + + def opacity(self): + return self._opacity + + def text(self): + return self._text + + def setText(self, text: str): + self._text = text + self.update() + + def showEvent(self, event): + self.resize(self.parent().size()) + super().showEvent(event) + def paintEvent(self, event): painter = QtGui.QPainter(self) - painter.setRenderHint(QtGui.QPainter.Antialiasing) - painter.fillRect(self.rect(), QtGui.QColor(0, 100, 255, 200)) # Semi-transparent blue - painter.setPen(Qt.white) + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + painter.fillRect(self.rect(), QtGui.QColor(0, 100, 255, self.opacityMap[self.opacity()])) # Semi-transparent blue + painter.setPen(Qt.GlobalColor.white) font = self.font() font.setBold(True) painter.setFont(font) - painter.drawText(self.rect(), Qt.AlignCenter, "Drop here to create new exchanges") + painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self.text()) diff --git a/activity_browser/ui/widgets/formula_edit.py b/activity_browser/ui/widgets/formula_edit.py index 0a1c6e564..47920f135 100644 --- a/activity_browser/ui/widgets/formula_edit.py +++ b/activity_browser/ui/widgets/formula_edit.py @@ -5,13 +5,13 @@ from asteval import make_symbol_table, Interpreter -from qtpy.QtWidgets import QApplication, QWidget, QCompleter, QTableView, QSizePolicy +from qtpy.QtWidgets import QApplication, QWidget, QCompleter, QTableView from qtpy.QtGui import QPainter, QColor, QFontMetrics, QFontDatabase, QPainterPath, QPen, QFont from qtpy.QtCore import QTimer, Qt, QAbstractTableModel, QModelIndex from activity_browser.static import fonts -QFontDatabase.addApplicationFont(fonts.__path__[0] + "/mono.ttf") + operators = r"+\-*/%=<>!&|^~" pattern = r"\b[a-zA-Z_]\w*\b|[\d.]+|[\"'{}:,+\-*/^()\[\]]| +" @@ -56,7 +56,9 @@ class Colors: class ABFormulaEdit(QWidget): - def __init__(self, parent=None, scope=None, text=None): + def __init__(self, parent=None, scope=None, text=None, simple=False): + QFontDatabase.addApplicationFont(fonts.__path__[0] + "/mono.ttf") + super().__init__(parent) self.scope = scope or {} self.error = False @@ -67,12 +69,16 @@ def __init__(self, parent=None, scope=None, text=None): self.scroll_offset = 0 # Scroll position for long text self.padding = 5 # Left padding for text inside the box self.dragging = False # Track if mouse is dragging + self.text = text or "" # Stores user input font = self.font() font.setFamily("JetBrains Mono") font.setPointSize(9) self.setFont(font) + if simple: + return + self.timer = QTimer(self) self.timer.timeout.connect(self.toggle_cursor) self.timer.start(500) # Blink cursor every 500ms @@ -87,8 +93,6 @@ def __init__(self, parent=None, scope=None, text=None): self.completer.setCompletionColumn(0) self.completer.activated.connect(self.insert_completion) - self.text = text or "" # Stores user input - @property def text(self): return self._text @@ -290,28 +294,45 @@ def get_cursor_position_from_x(self, x): x_offset = x - self.padding + self.scroll_offset cursor_pos = len(self.text) - for i in range(len(self.text)): - if font_metrics.horizontalAdvance(self.text[:i]) > x_offset: - cursor_pos = i - break + for i in range(len(self.text) + 1): + char_x = font_metrics.horizontalAdvance(self.text[:i]) + if i < len(self.text): + next_char_x = font_metrics.horizontalAdvance(self.text[:i + 1]) + mid_point = (char_x + next_char_x) / 2 + if x_offset < mid_point: + cursor_pos = i + break + else: + # Past the end of the text + if x_offset >= char_x: + cursor_pos = i + break return cursor_pos def mousePressEvent(self, event): """Handles mouse click events to set cursor position and start selection.""" - if 10 <= event.x() <= 390 and 10 <= event.y() <= 40: + if self.rect().contains(event.pos()): self.cursor_pos = self.get_cursor_position_from_x(event.x()) - self.selection_start = self.cursor_pos # Start selection + self.selection_start = None # Clear selection initially self.selection_end = None # Reset end position self.dragging = True # Start dragging self.adjust_scroll() - self.update() + self.cursor_visible = True # Show cursor immediately + self.update() # Force immediate redraw + self.timer.stop() # Stop the timer + self.timer.start(500) # Restart blink timer def mouseMoveEvent(self, event): """Handles mouse dragging for text selection.""" if self.dragging: - self.selection_end = self.get_cursor_position_from_x(event.x()) - self.cursor_pos = self.selection_end + new_pos = self.get_cursor_position_from_x(event.x()) + # Start selection on first move if not already started + if self.selection_start is None and new_pos != self.cursor_pos: + self.selection_start = self.cursor_pos + if self.selection_start is not None: + self.selection_end = new_pos + self.cursor_pos = new_pos self.adjust_scroll() self.update() @@ -333,6 +354,7 @@ def paintEvent(self, event): painter.setPen(Qt.NoPen) painter.fillRect(self.rect(), background_color) self.paint_text(painter) + painter.end() def paint_text(self, painter: QPainter): painter.setFont(self.font()) @@ -360,7 +382,7 @@ def paint_text(self, painter: QPainter): if not painter.pen() == Qt.NoPen: pass - if token_type == "NUMBER": + elif token_type == "NUMBER": painter.setPen(Colors.number) elif token_type in ["SQSTRING", "DQSTRING"]: painter.setPen(Colors.string) diff --git a/activity_browser/ui/widgets/line_edit.py b/activity_browser/ui/widgets/line_edit.py index 655d269d5..0a5c8ea3a 100644 --- a/activity_browser/ui/widgets/line_edit.py +++ b/activity_browser/ui/widgets/line_edit.py @@ -1,7 +1,6 @@ from qtpy import QtWidgets from qtpy.QtCore import QTimer, Slot, Signal, SignalInstance from qtpy.QtGui import QTextFormat -from qtpy.QtWidgets import QCompleter class ABLineEdit(QtWidgets.QLineEdit): @@ -49,12 +48,12 @@ def _text_changed(self, text: str) -> None: @Slot(name="customEditFinish") def _editing_finished(self) -> None: - from activity_browser import actions + from activity_browser import app after = self.text() if self._before != after: self._before = after - actions.ActivityModify.run(self._key, self._field, after) + app.actions.ActivityModify.run(self._key, self._field, after) class SignalledPlainTextEdit(QtWidgets.QPlainTextEdit): @@ -78,11 +77,11 @@ def highlight(self): self.setExtraSelections([selection]) def focusOutEvent(self, event): - from activity_browser import actions + from activity_browser import app after = self.toPlainText() if self._before != after: - actions.ActivityModify.run(self._key, self._field, after) + app.actions.ActivityModify.run(self._key, self._field, after) super().focusOutEvent(event) def refresh_text(self, text: str) -> None: @@ -104,19 +103,10 @@ def __init__(self, key, field, contents="", parent=None): self._field = field def focusOutEvent(self, event): - from activity_browser import actions + from activity_browser import app after = self.currentText() if self._before != after: self._before = after - actions.ActivityModify.run(self._key, self._field, after) + app.actions.ActivityModify.run(self._key, self._field, after) super(SignalledComboEdit, self).focusOutEvent(event) - - -class AutoCompleteLineEdit(QtWidgets.QLineEdit): - """Line Edit with a completer attached""" - - def __init__(self, items: list[str], parent=None): - super().__init__(parent=parent) - completer = QCompleter(items, self) - self.setCompleter(completer) diff --git a/activity_browser/ui/widgets/menu.py b/activity_browser/ui/widgets/menu.py index 52f332749..c0399b076 100644 --- a/activity_browser/ui/widgets/menu.py +++ b/activity_browser/ui/widgets/menu.py @@ -1,10 +1,10 @@ from qtpy import QtWidgets -from typing import Callable, Optional +from typing import Callable from inspect import signature class ABMenu(QtWidgets.QMenu): - menuSetup: list[Callable[["ABMenu", Optional[QtWidgets.QWidget]], None]] + menuSetup: list[Callable[["ABMenu", QtWidgets.QWidget], None]] title: str = None def __init__(self, pos=None, parent=None, title: str = None): @@ -19,3 +19,11 @@ def __init__(self, pos=None, parent=None, title: str = None): def add(self, action, *args, enable=True, text=None, **kwargs): qaction = action.get_QAction(*args, parent=self, enabled=enable, text=text, **kwargs) self.addAction(qaction) + + def callback(self, text: str, func: Callable, args: list = None, kwargs: dict = None): + args = args or [] + kwargs = kwargs or {} + + action = QtWidgets.QAction(text, self) + action.triggered.connect(lambda: func(*args, **kwargs)) + self.addAction(action) diff --git a/activity_browser/ui/widgets/plot.py b/activity_browser/ui/widgets/plot.py new file mode 100644 index 000000000..b9bdf1f27 --- /dev/null +++ b/activity_browser/ui/widgets/plot.py @@ -0,0 +1,64 @@ +from qtpy import QtWidgets + +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg +from matplotlib.figure import Figure + + +class ABPlot(QtWidgets.QWidget): + ALL_FILTER = "All Files (*.*)" + PNG_FILTER = "PNG (*.png)" + SVG_FILTER = "SVG (*.svg)" + + def __init__(self, parent=None): + super().__init__(parent) + # create figure, canvas, and axis + self.figure = Figure(constrained_layout=True) + self.canvas = FigureCanvasQTAgg(self.figure) + self.canvas.setMinimumHeight(0) + + self.ax = self.figure.add_subplot(111) # create an axis + self.plot_name = "Figure" + + # set the layout + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.canvas) + self.setLayout(layout) + self.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + self.updateGeometry() + + def plot(self, *args, **kwargs): + raise NotImplementedError + + def reset_plot(self) -> None: + self.figure.clf() + self.ax = self.figure.add_subplot(111) + + def get_canvas_size_in_inches(self): + return tuple(x / self.figure.dpi for x in self.canvas.get_width_height()) + + def to_png(self): + """Export to .png format.""" + from activity_browser.bwutils.commontasks import savefilepath + + filepath = savefilepath( + default_file_name=self.plot_name, file_filter=self.PNG_FILTER + ) + if filepath: + if not filepath.endswith(".png"): + filepath += ".png" + self.figure.savefig(filepath) + + def to_svg(self): + """Export to .svg format.""" + from activity_browser.bwutils.commontasks import savefilepath + + filepath = savefilepath( + default_file_name=self.plot_name, file_filter=self.SVG_FILTER + ) + if filepath: + if not filepath.endswith(".svg"): + filepath += ".svg" + self.figure.savefig(filepath) + diff --git a/activity_browser/ui/widgets/tab_widget.py b/activity_browser/ui/widgets/tab_widget.py new file mode 100644 index 000000000..d3a00561b --- /dev/null +++ b/activity_browser/ui/widgets/tab_widget.py @@ -0,0 +1,61 @@ +from qtpy import QtWidgets + +from .buttons import ABCloseButton, ABMinimizeButton + + +class ABTabWidget(QtWidgets.QTabWidget): + def __init__(self, *args, **kwargs): + """ + Initialize the GroupTabWidget. + + Args: + name (str): The name of the group, used as the object name for the widget. + *args: Additional positional arguments passed to the parent QTabWidget. + """ + super().__init__(*args, **kwargs) + self.setMovable(True) # Allow tabs to be rearranged. + self.setTabsClosable(True) # Allow tabs to be closed. + self.tabBar().setExpanding(False) + + + def resizeEvent(self, event): + super().resizeEvent(event) + # Force the tab bar to always fill the full width + self.tabBar().setMinimumWidth(self.width()) + + def addTab(self, widget, label, show_minimize=False): + """Override addTab to add custom buttons to each tab. + + Args: + widget: The widget to add as a tab + label: The label for the tab + show_minimize: If True, show minimize button; if False, show close button + """ + index = super().addTab(widget, label) + self._set_buttons(index, widget, show_minimize) + return index + + def insertTab(self, index, widget, label, show_minimize=False): + """Override insertTab to add custom buttons to each tab. + + Args: + index: The index at which to insert the tab + widget: The widget to add as a tab + label: The label for the tab + show_minimize: If True, show minimize button; if False, show close button + """ + index = super().insertTab(index, widget, label) + self._set_buttons(index, widget, show_minimize) + return index + + def _set_buttons(self, index, widget, show_minimize=False): + tab_bar = self.tabBar() + button = ABMinimizeButton() if show_minimize else ABCloseButton() + tab_bar.setTabButton(index, QtWidgets.QTabBar.ButtonPosition.RightSide, button) + button.clicked.connect(lambda w=widget: self.closeTabByWidget(w)) + + def closeTabByWidget(self, widget): + """Handle close button click using the widget reference.""" + index = self.indexOf(widget) + if index >= 0: + self.tabCloseRequested.emit(index) diff --git a/activity_browser/ui/widgets/text_edit.py b/activity_browser/ui/widgets/text_edit.py new file mode 100644 index 000000000..7c1a20a4f --- /dev/null +++ b/activity_browser/ui/widgets/text_edit.py @@ -0,0 +1,255 @@ +from qtpy import QtWidgets +from qtpy.QtCore import QTimer, Signal, SignalInstance, QStringListModel, Qt +from qtpy.QtGui import QSyntaxHighlighter, QTextCharFormat, QTextDocument, QFont +from qtpy.QtWidgets import QCompleter, QStyledItemDelegate, QStyle + + +class UnknownWordHighlighter(QSyntaxHighlighter): + def __init__(self, parent: QTextDocument, known_words: set): + super().__init__(parent) + self.known_words = known_words + + # define the format for unknown words + self.unknown_format = QTextCharFormat() + self.unknown_format.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline) + self.unknown_format.setUnderlineColor(Qt.red) + + def highlightBlock(self, text: str): + if text.startswith("="): + return + words = text.split() + index = 0 + for word in words: + word_len = len(word) + if word and word not in self.known_words: + self.setFormat(index, word_len, self.unknown_format) + index += word_len + 1 # +1 for the space + + +class AutoCompleteDelegate(QStyledItemDelegate): + def __init__(self, parent=None): + super().__init__(parent) + self.current_word_index = -1 + + def paint(self, painter, option, index): + text = index.data(Qt.DisplayRole) + + painter.save() + + # Draw selection background if selected + if option.state & QStyle.State_Selected: + painter.fillRect(option.rect, option.palette.highlight()) + painter.setPen(option.palette.highlightedText().color()) + else: + painter.setPen(option.palette.text().color()) + + # Split text into words and draw each with appropriate font + words = text.split(" ") + x = option.rect.x() + y = option.rect.y() + spacing = 4 # space between words + font = option.font + metrics = painter.fontMetrics() + + for i, word in enumerate(words): + word_font = QFont(font) + if i+1 == self.current_word_index: + word_font.setBold(True) + painter.setFont(word_font) + + word_width = metrics.horizontalAdvance(word) + painter.drawText(x, y + metrics.ascent() + (option.rect.height() - metrics.height()) // 2, word) + x += word_width + spacing + painter.restore() + + +class ABTextEdit(QtWidgets.QTextEdit): + textChangedDebounce: SignalInstance = Signal(str) + _debounce_ms = 250 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._debounce_timer = QTimer(self, singleShot=True) + + self.textChanged.connect(self._set_debounce) + self._debounce_timer.timeout.connect(self._emit_debounce) + + def _set_debounce(self): + self._debounce_timer.setInterval(self._debounce_ms) + self._debounce_timer.start() + + def _emit_debounce(self): + self.textChangedDebounce.emit(self.toPlainText()) + + def debounce(self): + return self._debounce_ms + + def setDebounce(self, ms: int): + self._debounce_ms = ms + + +class ABAutoCompleTextEdit(ABTextEdit): + def __init__(self, parent=None, highlight_unknown=False): + from activity_browser.bwutils.metadata import MetaDataStore # avoid circular import, should we refactor? + + self.mds = MetaDataStore() + super().__init__(parent=parent) + + self.auto_complete_word = "" + + # autocompleter settings + self.model = QStringListModel() + self.completer = QCompleter(self.model) + self.completer.setWidget(self) + self.popup = self.completer.popup() + self.delegate = AutoCompleteDelegate(self.popup) # set custom delegate to bold the current word + self.popup.setItemDelegate(self.delegate) + self.popup.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.completer.setPopup(self.popup) + self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) # allow all items in popup list + self.completer.activated.connect(self._insert_auto_complete) + + self.textChanged.connect(self._sanitize_input) + if highlight_unknown: + self.highlighter = UnknownWordHighlighter(self.document(), set()) + self.cursorPositionChanged.connect(self._set_autocomplete_items) + + def keyPressEvent(self, event): + key = event.key() + + if key in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Tab): + # insert an autocomplete item + # capture enter/return/tab key + index = self.popup.currentIndex() + completion_text = index.data(Qt.DisplayRole) + self.completer.activated.emit(completion_text) + return + elif key in (Qt.Key_Space,): + self.popup.close() + + super().keyPressEvent(event) + + # trigger on text input keys + if event.text() or key in (Qt.LeftArrow, Qt.RightArrow): # filters out non-text keys except l/r arrows + self._set_autocomplete_items() + + def _sanitize_input(self): + raise NotImplementedError + + def _set_autocomplete_items(self): + raise NotImplementedError + + def _insert_auto_complete(self, completion): + cursor = self.textCursor() + position = cursor.position() + completion = completion + " " # add space to end of new text + + # find where to put cursor back + new_position = position + while new_position < len(completion) and completion[new_position] != " ": + new_position += 1 + new_position += 1 # add one char for space + + # set new text from completion + self.blockSignals(True) + self.clear() + self.setText(completion) + # set the cursor location + cursor.setPosition(min(new_position, len(completion))) + self.setTextCursor(cursor) + self.blockSignals(False) + + # house keeping + self._emit_debounce() + self.popup.close() + self.auto_complete_word = "" + self.model.setStringList([]) + + +class MetaDataAutoCompleteTextEdit(ABAutoCompleTextEdit): + """TextEdit with MetaDataStore completer attached.""" + def __init__(self, parent=None): + super().__init__(parent=parent, highlight_unknown=True) + self.database_name = "" + + def _sanitize_input(self): + if not self.mds.searcher: + return + + self._debounce_timer.stop() + text = self.toPlainText() + clean_text = self.mds.searcher.ONE_SPACE_PATTERN.sub(" ", text) + + if clean_text != text: + cursor = self.textCursor() + position = cursor.position() + self.blockSignals(True) + self.clear() + self.insertPlainText(clean_text) + self.blockSignals(False) + cursor.setPosition(min(position, len(clean_text))) + self.setTextCursor(cursor) + + known_words = set() + for identifier in self.mds.searcher.database_id_manager(self.database_name): + known_words.update(self.mds.searcher.identifier_to_word[identifier].keys()) + self.highlighter.known_words = known_words + + if len(text) == 0: + self.popup.close() + self._set_debounce() + + def _set_autocomplete_items(self): + if not self.mds.searcher: + return + + text = self.toPlainText() + if text.startswith("="): + self.model.setStringList([]) + self.auto_complete_word = "" + self.popup.close() + return + + # find the start and end of the word under the cursor + cursor = self.textCursor() + position = cursor.position() + start = position + while start > 0 and text[start - 1] != " ": + start -= 1 + end = position + while end < len(text) and text[end] != " ": + end += 1 + current_word = text[start:end] + if not current_word: + self.model.setStringList([]) + self.popup.close() + self.auto_complete_word = "" + return + if self.auto_complete_word == current_word: + # avoid unnecessary auto_complete calls if the current word didnt change + return + self.auto_complete_word = current_word + + context = set((text[:start] + text[end:]).split(" ")) + self.delegate.current_word_index = len(text[:start].split(" ")) # current word index for bolding + # get suggestions for the current word + suggestions = self.mds.searcher.auto_complete(current_word, context=context, database=self.database_name) + suggestions = suggestions[:6] # at most 6, though we should get ~3 usually + # replace the current word with each alternative + items = [] + for alt in suggestions: + new_text = text[:start] + alt + text[end:] + items.append(new_text) + if len(items) == 0: + self.popup.close() + return + + self.model.setStringList(items) + # set correct height now that we have data + max_height = max( + 20, + self.popup.sizeHintForRow(0) * 3 + 2 * self.popup.frameWidth() + ) + self.popup.setMaximumHeight(max_height) + self.completer.complete() diff --git a/activity_browser/ui/widgets/tree_view.py b/activity_browser/ui/widgets/tree_view.py new file mode 100644 index 000000000..a94ee9777 --- /dev/null +++ b/activity_browser/ui/widgets/tree_view.py @@ -0,0 +1,259 @@ +from loguru import logger + +from qtpy import QtWidgets, QtCore, QtGui + +from activity_browser.ui import core + +from .line_edit import ABLineEdit + + +class ABTreeView(QtWidgets.QTreeView): + # fired when the filter is applied, fires False when an exception happens during querying + filtered: QtCore.SignalInstance = QtCore.Signal(bool) + + defaultColumnDelegates = {} + + class HeaderMenu(QtWidgets.QMenu): + def __init__(self, pos: QtCore.QPoint, view: "ABTreeView"): + super().__init__(view) + + model = view.model() + + col_index = view.columnAt(pos.x()) + col_name = model.columns()[col_index] + + search_box = ABLineEdit(self) + search_box.setText(view.columnFilters.get(col_name, "")) + search_box.setPlaceholderText("Search") + search_box.selectAll() + search_box.textChangedDebounce.connect(lambda query: view.setColumnFilter(col_name, query)) + widget_action = QtWidgets.QWidgetAction(self) + widget_action.setDefaultWidget(search_box) + self.addAction(widget_action) + + self.addAction(QtGui.QIcon(), "Group by column", lambda: model.group([col_name])) + self.addAction(QtGui.QIcon(), "Ungroup", model.ungroup) + self.addAction(QtGui.QIcon(), "Clear column filter", lambda: view.setColumnFilter(col_name, "")) + self.addAction(QtGui.QIcon(), "Clear all filters", + lambda: [view.setColumnFilter(name, "") for name in list(view.columnFilters.keys())], + ) + self.addSeparator() + + def toggle_slot(action: QtWidgets.QAction): + index = action.data() + hidden = view.isColumnHidden(index) + view.setColumnHidden(index, not hidden) + + view_menu = QtWidgets.QMenu(view) + view_menu.setTitle("View") + self.view_actions = [] + + for i in range(1, len(model.columns())): + action = QtWidgets.QAction(model.columns()[i]) + action.setCheckable(True) + action.setChecked(not view.isColumnHidden(i)) + action.setData(i) + view_menu.addAction(action) + self.view_actions.append(action) + + view_menu.triggered.connect(toggle_slot) + + self.addMenu(view_menu) + + search_box.setFocus() + + class ContextMenu(QtWidgets.QMenu): + def __init__(self, pos, view): + super().__init__(view) + + def __init__(self, parent=None): + from activity_browser.ui import delegates + + super().__init__(parent) + self.setIndentation(10) + self.setItemDelegate(delegates.StringDelegate(self)) + + self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.showContextMenu) + + self.setSelectionBehavior(QtWidgets.QTreeView.SelectionBehavior.SelectRows) + self.setSelectionMode(QtWidgets.QTreeView.SelectionMode.ExtendedSelection) + + self.header().setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.header().customContextMenuRequested.connect(self.showHeaderMenu) + + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel) + + self.columnFilters: dict[str, str] = {} # dict[column_name, query] for filtering the dataframe + self.allFilter: str = "" # filter applied to the entire dataframe + + def setModel(self, model): + super().setModel(model) + + self.setColumnWidth(0, 20) + self.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Fixed) + + model.modelAboutToBeReset.connect(self.clearColumnDelegates) + model.modelReset.connect(self.updateIndexColumnVisibility) + model.modelReset.connect(self.setDefaultColumnDelegates) + model.modelReset.connect(self.updateBranchSpanning, QtCore.Qt.ConnectionType.QueuedConnection) + model.layoutChanged.connect(self.updateIndexColumnVisibility) + model.layoutChanged.connect(self.updateBranchSpanning, QtCore.Qt.ConnectionType.QueuedConnection) + model.rowsInserted.connect(self.updateBranchSpanningForInsertedRows, QtCore.Qt.ConnectionType.QueuedConnection) + + self.setDefaultColumnDelegates() + self.updateIndexColumnVisibility() + self.updateBranchSpanning() + + def model(self) -> core.ABTreeModel: + return super().model() + + # === Functionality related to contextmenus + + def showContextMenu(self, pos): + self.ContextMenu(pos, self).exec_(self.mapToGlobal(pos)) + + def showHeaderMenu(self, pos): + self.HeaderMenu(pos, self).exec_(self.mapToGlobal(pos)) + + def setColumnFilter(self, column_name: str, query: str): + """ + Set a filter for a specific column using a string query. If the query is empty remove the filter from the column + """ + col_index = self.model().columns().index(column_name) + + if query: + self.columnFilters[column_name] = query + self.model().filtered_columns.add(col_index) + elif column_name in self.columnFilters: + del self.columnFilters[column_name] + self.model().filtered_columns.discard(col_index) + + self.applyFilter() + + # === Functionality related to filtering + + def setAllFilter(self, query: str): + self.allFilter = query + self.applyFilter() + + def buildQuery(self) -> str: + queries = [] + + # query for the column filters + for col in list(self.columnFilters): + if col not in self.model().columns(): + del self.columnFilters[col] + + for col, query in self.columnFilters.items(): + q = f"({col}.astype('str').str.contains('{self.format_query(query)}', False))" + queries.append(q) + + # query for the all filter + if self.allFilter.startswith('='): + queries.append(f"({self.allFilter[1:]})") + else: + all_queries = [] + formatted_filter = self.format_query(self.allFilter) + + for i, col in enumerate(self.model().columns()): + if col == "index" or self.isColumnHidden(i): + continue + all_queries.append(f"(`{col}`.astype('str').str.contains('{formatted_filter}', False))") + + q = f"({' | '.join(all_queries)})" + queries.append(q) + + query = " & ".join(queries) + logger.debug(f"{self.__class__.__name__} built query: {query}") + + return query + + def applyFilter(self): + query = self.buildQuery() + try: + self.model().filter("ABTreeView", query) + self.filtered.emit(True) + except Exception as e: + logger.info(f"{self.__class__.__name__} {type(e).__name__} in query: {e}") + self.filtered.emit(False) + + @staticmethod + def format_query(query: str) -> str: + return query.translate(str.maketrans({'(': '\\(', ')': '\\)', "'": "\\'"})) + + # === Functionality related to setting the column delegates + def clearColumnDelegates(self): + for i in range(self.model().columnCount()): + self.setItemDelegateForColumn(i, None) + + def setDefaultColumnDelegates(self): + columns = self.model().columns() + for i, col_name in enumerate(columns): + if col_name in self.defaultColumnDelegates: + delegate = self.defaultColumnDelegates[col_name](self) + self.setItemDelegateForColumn(i, delegate) + elif col_name.startswith("property_"): + self.setItemDelegateForColumn(i, self.propertyDelegate) + + def updateIndexColumnVisibility(self): + """Hide the index column (column 0) if the dataframe index is only one level deep.""" + model = self.model() + if model is None: + return + + # Check if model has the df attribute (ABTreeModel style) + if hasattr(model, 'df') and hasattr(model.df, 'index'): + # Hide index column if it's only one level deep + hide_index = model.df.index.nlevels == 1 + self.setColumnHidden(0, hide_index) + + def updateBranchSpanning(self): + """Enable spanning for branch nodes so they span across all columns.""" + model = self.model() + if model is None or not hasattr(model, 'isBranchNode'): + return + + # Recursively set spanning for all branch nodes + self._setSpanningRecursive(QtCore.QModelIndex()) + + def updateBranchSpanningForInsertedRows(self, parent: QtCore.QModelIndex, first: int, last: int): + """Update spanning for newly inserted rows during lazy loading.""" + model = self.model() + if model is None or not hasattr(model, 'isBranchNode'): + return + + # Set spanning for the newly inserted rows + for row in range(first, last + 1): + index = model.index(row, 0, parent) + if not index.isValid(): + continue + + # Check if this is a branch node + if model.isBranchNode(index): + self.setFirstColumnSpanned(row, parent, True) + # Recursively process children of this branch node + self._setSpanningRecursive(index) + else: + self.setFirstColumnSpanned(row, parent, False) + + def _setSpanningRecursive(self, parent: QtCore.QModelIndex): + """Recursively set first column spanning for branch nodes.""" + model = self.model() + if model is None: + return + + row_count = model.rowCount(parent) + for row in range(row_count): + index = model.index(row, 0, parent) + if not index.isValid(): + continue + + # Check if this is a branch node + if hasattr(model, 'isBranchNode') and model.isBranchNode(index): + self.setFirstColumnSpanned(row, parent, True) + # Recursively process children + self._setSpanningRecursive(index) + else: + self.setFirstColumnSpanned(row, parent, False) + diff --git a/activity_browser/ui/widgets/web_engine_page.py b/activity_browser/ui/widgets/web_engine_page.py new file mode 100644 index 000000000..07f0a67ea --- /dev/null +++ b/activity_browser/ui/widgets/web_engine_page.py @@ -0,0 +1,15 @@ +from loguru import logger + +from qtpy.QtWebEngineWidgets import QWebEnginePage + + +class ABWebEnginePage(QWebEnginePage): + def javaScriptConsoleMessage(self, level: QWebEnginePage.JavaScriptConsoleMessageLevel, message: str, line: str, _: str): + if level == QWebEnginePage.InfoMessageLevel: + logger.info(f"JS Info (Line {line}): {message}") + elif level == QWebEnginePage.WarningMessageLevel: + logger.warning(f"JS Warning (Line {line}): {message}") + elif level == QWebEnginePage.ErrorMessageLevel: + logger.error(f"JS Error (Line {line}): {message}") + else: + logger.debug(f"JS Log (Line {line}): {message}") diff --git a/activity_browser/ui/widgets/wizard.py b/activity_browser/ui/widgets/wizard.py index aa2834c8f..e5b599998 100644 --- a/activity_browser/ui/widgets/wizard.py +++ b/activity_browser/ui/widgets/wizard.py @@ -1,17 +1,38 @@ -from typing import TYPE_CHECKING -from qtpy import QtWidgets +from typing import TYPE_CHECKING, Literal +from qtpy import QtWidgets, QtCore if TYPE_CHECKING: from activity_browser.ui.widgets import ABWizardPage +ABWizardButtons = Literal[ + "Stretch", + "BackButton", + "NextButton", + "CancelButton", + "FinishButton", + "HelpButton", + "CommitButton", +] + +ABWizardButtonLayout = list[ABWizardButtons] + + class ABWizard(QtWidgets.QWizard): pages = [] + context = {} + defaultButtonLayout: ABWizardButtonLayout = ["Stretch", "BackButton", "NextButton", "CancelButton"] + finalButtonLayout: ABWizardButtonLayout = ["Stretch", "FinishButton"] def __init__(self, *args, title: str = None, context: dict = None, **kwargs): super().__init__(*args, **kwargs) self.setWizardStyle(QtWidgets.QWizard.WizardStyle.ModernStyle) + self.setWindowFlags( + QtCore.Qt.WindowType.Sheet | + QtCore.Qt.WindowType.CustomizeWindowHint | + QtCore.Qt.WindowType.WindowTitleHint + ) if title: self.setWindowTitle(title) @@ -19,6 +40,18 @@ def __init__(self, *args, title: str = None, context: dict = None, **kwargs): for page in self.pages: self.addPage(page(self)) + text, callback = self.customButtonOne() + self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton1, text) + self.button(QtWidgets.QWizard.WizardButton.CustomButton1).clicked.connect(callback) + + text, callback = self.customButtonTwo() + self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton2, text) + self.button(QtWidgets.QWizard.WizardButton.CustomButton2).clicked.connect(callback) + + text, callback = self.customButtonThree() + self.setButtonText(QtWidgets.QWizard.WizardButton.CustomButton3, text) + self.button(QtWidgets.QWizard.WizardButton.CustomButton3).clicked.connect(callback) + self.context = context or {} def page(self, page_id: int) -> "ABWizardPage": @@ -35,3 +68,57 @@ def initializePage(self, page_id): # initialize the next page page = self.page(page_id) page.initializePage(self.context) + + if page.buttonLayout: + if "CommitButton" in page.buttonLayout: + page.setCommitPage(True) + if "FinishButton" in page.buttonLayout: + page.setFinalPage(True) + + self.setButtonLayout(page.buttonLayout) + + elif self.currentId() == self.pageIds()[-1]: + self.setButtonLayout(self.finalButtonLayout) + + else: + self.setButtonLayout(self.defaultButtonLayout) + + def setButtonLayout(self, layout: ABWizardButtonLayout): + button_map = { + "Stretch": QtWidgets.QWizard.WizardButton.Stretch, + "BackButton": QtWidgets.QWizard.WizardButton.BackButton, + "NextButton": QtWidgets.QWizard.WizardButton.NextButton, + "CancelButton": QtWidgets.QWizard.WizardButton.CancelButton, + "FinishButton": QtWidgets.QWizard.WizardButton.FinishButton, + "HelpButton": QtWidgets.QWizard.WizardButton.HelpButton, + "CommitButton": QtWidgets.QWizard.WizardButton.CommitButton, + "CustomButton1": QtWidgets.QWizard.WizardButton.CustomButton1, + "CustomButton2": QtWidgets.QWizard.WizardButton.CustomButton2, + "CustomButton3": QtWidgets.QWizard.WizardButton.CustomButton3, + } + qt_layout = [button_map[item] for item in layout] + super().setButtonLayout(qt_layout) + + default_button = "NextButton" + default_button = "FinishButton" if "FinishButton" in layout else default_button + default_button = "CommitButton" if "CommitButton" in layout else default_button + + # Set the default button after a short delay to ensure the UI is updated + def set_default(): + try: + button = self.button(button_map[default_button]) + button.setFocus() + except RuntimeError: + # Wizard might be closed before the timer fires + pass + + QtCore.QTimer.singleShot(50, set_default) + + def customButtonOne(self): + return "CustomButton1", lambda: None + + def customButtonTwo(self): + return "CustomButton2", lambda: None + + def customButtonThree(self): + return "CustomButton3", lambda: None diff --git a/activity_browser/ui/widgets/wizard_page.py b/activity_browser/ui/widgets/wizard_page.py index 122ebd2c8..446617b9a 100644 --- a/activity_browser/ui/widgets/wizard_page.py +++ b/activity_browser/ui/widgets/wizard_page.py @@ -2,13 +2,14 @@ from qtpy import QtWidgets if TYPE_CHECKING: - from activity_browser.ui.widgets import ABWizard + from .wizard import ABWizard, ABWizardButtonLayout from activity_browser.ui.core.threading import ABThread class ABWizardPage(QtWidgets.QWizardPage): title: str = "" subtitle: str = "" + buttonLayout: "ABWizardButtonLayout" = [] def __init__(self, parent=None): super().__init__(parent) @@ -36,12 +37,15 @@ def initializePage(self, context: dict): def finalize(self, context: dict): pass + def context(self) -> dict: + return self.wizard().context + class ABThreadedWizardPage(ABWizardPage): Thread: type["ABThread"] def __init__(self, parent=None): - from activity_browser import application + from activity_browser.app import application super().__init__(parent) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..30a08a194 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,205 @@ +# docs + +Documentation for Activity Browser. + +## Overview + +This directory contains the source files for Activity Browser's documentation website, which is built using Jekyll and hosted on GitHub Pages. + +## Structure + +- **Jekyll Site Configuration** + - `_config.yml` - Jekyll site configuration + - `Gemfile` - Ruby gem dependencies + - `404.html` - Custom 404 error page + - `index.md` - Documentation homepage + +- **`_includes/`** - Reusable HTML/Liquid templates + - `nav_footer_custom.html` - Custom navigation footer + - `search_placeholder_custom.html` - Custom search placeholder + +- **`_sass/`** - SASS/CSS stylesheets + - `custom/` - Custom styling overrides + +- **`getting-started/`** - Getting started guides + - `installation.md` - Installation instructions + - `project-setup.md` - Setting up your first project + - `creating-databases.md` - Creating and managing databases + - `building-models.md` - Building LCA models + - `lca-calculations.md` - Running LCA calculations + - `index.md` - Getting started overview + +- **`user-interface/`** - UI documentation + - `pages/` - Documentation for each page + - `index.md` - UI overview + +- **`advanced-topics/`** - Advanced features + - `project-structure.md` - Understanding project structure + - `scenario-calculations.md` - Scenario analysis + - `brightway-legacy.md` - Working with Brightway legacy versions + - `multifunctional-databases/` - Multi-functionality documentation + - `index.md` - Advanced topics overview + +- **`assets/`** - Images, screenshots, and other assets + +- **`beta.md`** - Beta version information + +## Building Documentation + +### Prerequisites +- Ruby (for Jekyll) +- Bundler gem + +### Local Development + +1. Install dependencies: + ```bash + cd docs + bundle install + ``` + +2. Serve locally: + ```bash + bundle exec jekyll serve + ``` + +3. View at: `http://localhost:4000` + +### Live Documentation + +The documentation is automatically built and deployed to GitHub Pages when changes are pushed to the repository. + +URL: [https://lca-activitybrowser.github.io/activity-browser/](https://lca-activitybrowser.github.io/activity-browser/) + +## Writing Documentation + +### Markdown Files + +Documentation is written in Markdown with Jekyll front matter: + +```markdown +--- +layout: default +title: Page Title +nav_order: 1 +--- + +# Page Title + +Content goes here... +``` + +### Front Matter Options + +- **`layout`** - Page layout template (usually `default`) +- **`title`** - Page title +- **`nav_order`** - Navigation menu order +- **`parent`** - Parent page for nested navigation +- **`has_children`** - Whether page has child pages +- **`permalink`** - Custom URL path + +### Linking Pages + +Use relative links: +```markdown +See [Installation Guide]({% link getting-started/installation.md %}) +``` + +### Including Images + +Place images in `assets/` and reference: +```markdown +![Screenshot](../assets/screenshot.png) +``` + +### Code Blocks + +Use fenced code blocks with language: +```markdown +```python +import bw2data as bd +bd.projects.set_current("my_project") +``` +``` + +## Documentation Structure + +### Getting Started +Target audience: New users +- Installation +- First project +- Basic concepts +- First calculation + +### User Interface +Target audience: All users +- Navigation +- Pages and panes +- Common tasks +- Keyboard shortcuts + +### Advanced Topics +Target audience: Power users +- Scenarios and parameters +- Uncertainty analysis +- Sensitivity analysis +- Multi-functionality +- Integration with Brightway + +## Style Guide + +### Writing Style +- **Clear and concise** - Simple language +- **Task-oriented** - Focus on what users want to do +- **Step-by-step** - Break down complex tasks +- **Visual aids** - Screenshots and diagrams +- **Examples** - Show real examples + +### Formatting +- **Headings** - Use proper hierarchy (H1, H2, H3) +- **Lists** - For steps or multiple items +- **Bold** - For UI elements and important terms +- **Code** - For code, commands, and file paths +- **Notes/Tips** - Use blockquotes for callouts + +### Screenshots +- Use actual application screenshots +- Highlight relevant areas +- Keep up-to-date with current UI +- Crop to show only relevant content +- Use consistent window size + +## Maintenance + +### Keeping Current +- Update screenshots when UI changes +- Verify instructions after code changes +- Add documentation for new features +- Mark deprecated features +- Update version numbers + +### Review Process +- Test instructions on fresh install +- Check all links work +- Verify code examples +- Review for clarity +- Check mobile responsiveness + +## Contributing + +To contribute to documentation: + +1. Fork the repository +2. Create a branch for your changes +3. Edit/add Markdown files in `docs/` +4. Test locally with Jekyll +5. Submit a pull request + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for more details. + +## Resources + +- [Jekyll Documentation](https://jekyllrb.com/docs/) +- [Just the Docs Theme](https://just-the-docs.github.io/just-the-docs/) +- [Markdown Guide](https://www.markdownguide.org/) +- [GitHub Pages](https://pages.github.com/) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index da0998ece..1c13a1944 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -35,8 +35,7 @@ For more elaborate installing instructions check out the page below for both [in ## Installing from PyPI Installing from the Python Package Index (PyPI) can be done using the standard `pip` command. We strongly recommended installing the Activity Browser into a separate [virtual environment](https://realpython.com/python-virtual-environments-a-primer/) -First make sure you have Python installed on your PC by entering the following command into your terminal or command prompt. -At this moment the AB is compatible with Python versions 3.10, 3.11, and 3.12. +First make sure you have Python installed on your PC by entering the following command into your terminal or command prompt. ``` python --version @@ -52,7 +51,6 @@ Afterwards, you need to activate the virtual environment, which differs between ``` C:\Users\me\virtualenvs\ab-beta\Scripts\activate.bat ``` -Possibly double-check if the Python version of your virtual environment is compatible with the AB. For a full overview of activation commands, [check out the documentation here](https://docs.python.org/3/library/venv.html#how-venvs-work) ### Activity Browser installation diff --git a/docs/img.png b/docs/img.png new file mode 100644 index 0000000000000000000000000000000000000000..47db4e6ed483f49d93ac80330e5c9fc3e10ede8b GIT binary patch literal 576 zcmeAS@N?(olHy`uVBq!ia0y~yVD$pB>o}NzWG&yG`wR?B?4B-;Ar*0N4;nH81rIIw l>YvLIzW~S_1tTZ~;+QXJGcfW#INApCw5O||%Q~loCIE=0.11.5", "bw2calc>=2.0", - "bw2data>=4.1", + "bw2data>=4.1, <4.5.2", "bw2parameters>=1.1", "bw2io>=0.9.3", "bw_graph_tools>=0.5", @@ -43,19 +43,20 @@ dependencies = [ "bw_simapro_csv >=0.2.6", "ecoinvent_interface", "matrix_utils>=0.5", - "bw-functional==0b94", + "bw-functional==0b97", "networkx", "numpy>=1.23.5,<2", "pandas>=2.2.1", "pint<=0.21", "py7zr==0.22.0", "pyperclip", - "pyside6>=6.5.0, <6.10", + "pyside6", "pypardiso ; platform_system == 'Windows'", "pyprind", "qtpy", "salib>=1.4", "seaborn", + "loguru>=0.7", ] diff --git a/recipe/README.md b/recipe/README.md new file mode 100644 index 000000000..bb76dc36b --- /dev/null +++ b/recipe/README.md @@ -0,0 +1,192 @@ +# recipe + +Conda build recipe for Activity Browser. + +## Overview + +This directory contains the conda-build recipe for packaging and distributing Activity Browser via conda-forge. The recipe defines how to build the conda package from source. + +## Key File + +- **`meta.yaml`** - Conda package metadata and build instructions + +## meta.yaml Structure + +The `meta.yaml` file contains several sections: + +### Package Section +Defines package name and version: +```yaml +package: + name: activity-browser + version: {{ VERSION }} +``` + +### Source Section +Specifies where to get the source code: +```yaml +source: + path: .. # Local path for development + # Or from GitHub release: + # url: https://github.com/LCA-ActivityBrowser/activity-browser/archive/{{ version }}.tar.gz +``` + +### Build Section +Build configuration: +```yaml +build: + number: 0 + noarch: python # Pure Python package + entry_points: + - activity-browser = activity_browser:run_activity_browser +``` + +### Requirements Section +Dependencies for build and runtime: + +```yaml +requirements: + host: + - python >=3.9 + - pip + - setuptools + run: + - python >=3.9 + - brightway2 >=2.4 + - pyside6 >=6.0 + - qtpy >=2.0 + # ... more dependencies +``` + +### About Section +Package metadata: +```yaml +about: + home: https://github.com/LCA-ActivityBrowser/activity-browser + license: LGPL-3.0 + summary: GUI for Brightway2 LCA framework + description: Activity Browser is a GUI for the Brightway2 LCA framework + doc_url: https://lca-activitybrowser.github.io/activity-browser/ +``` + +## Building Locally + +### Prerequisites +- conda-build installed: `conda install conda-build` +- Conda environment set up + +### Build Command +```bash +conda build recipe/ +``` + +This will: +1. Create a clean build environment +2. Install dependencies +3. Build the package from source +4. Run tests +5. Create a conda package (.tar.bz2) + +### Build Variants +For different Python versions: +```bash +conda build recipe/ --python 3.9 +conda build recipe/ --python 3.10 +conda build recipe/ --python 3.11 +``` + +## conda-forge + +Activity Browser is distributed via conda-forge, the community-led conda package repository. + +### conda-forge Repository +The conda-forge recipe is maintained in a separate repository: +https://github.com/conda-forge/activity-browser-feedstock + +### Update Process +When a new version is released: +1. conda-forge bot detects new GitHub release +2. Opens PR to update version and SHA256 +3. Maintainers review and merge +4. Package is built for all platforms +5. Published to conda-forge channel + +### Maintainers +conda-forge package maintainers can: +- Update the recipe +- Adjust dependencies +- Fix build issues +- Release new versions + +## Installation + +Users install from conda-forge: +```bash +conda install -c conda-forge activity-browser +``` + +Or with mamba (faster): +```bash +mamba install -c conda-forge activity-browser +``` + +## Dependencies + +Keep dependencies in sync: +- `meta.yaml` (conda recipe) +- `pyproject.toml` (pip/setuptools) +- `setup.py` (legacy setup) + +Ensure all three specify the same dependencies and versions. + +## Platform Support + +Activity Browser supports: +- **Linux** - x86_64, aarch64 +- **macOS** - x86_64, arm64 (Apple Silicon) +- **Windows** - x86_64 + +The recipe should specify `noarch: python` if the package is pure Python, or include platform-specific builds if needed. + +## Troubleshooting + +### Build Failures +- Check dependency versions +- Verify source path/URL +- Review build logs +- Test in clean environment + +### Import Errors +- Missing dependencies in run requirements +- Incorrect entry points +- Module import issues + +### Test Failures +- Tests timing out +- Missing test dependencies +- Platform-specific issues + +## Development Workflow + +1. **Local Development** + - Edit source code + - Test locally with `python -m activity_browser` + +2. **Update Recipe** + - Modify `meta.yaml` if dependencies changed + - Update version number + +3. **Build and Test** + - Run `conda build recipe/` + - Install and test locally + +4. **Release** + - Tag release on GitHub + - conda-forge bot updates feedstock + - Package published automatically + +## Resources + +- [conda-build documentation](https://docs.conda.io/projects/conda-build/) +- [conda-forge documentation](https://conda-forge.org/docs/) +- [Activity Browser feedstock](https://github.com/conda-forge/activity-browser-feedstock) diff --git a/recipe/meta.yaml b/recipe/meta.yaml index 494698ec5..82364aabd 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -21,19 +21,18 @@ requirements: - python >=3.10, <3.12 - arrow - bw2analyzer >=0.11.5 - - bw2data >=4.1 + - bw2data >=4.1, <4.5.2 - bw2parameters >=1.1 - bw2io >=0.9.3 - bw_graph_tools >=0.5 - bw_processing >=1.0 - bw_simapro_csv >=0.2.6 - - bw_functional=0.b.94 + - bw_functional=0.b.97 - ecoinvent_interface - matrix_utils >=0.5 - - networkx - numpy >=1.23.5, <2 - - pandas >=2.2.1 - - py7zr >=0.22.0 + - pint <=0.21 + - py7zr <=0.22.0 - pyperclip - pyprind - networkx diff --git a/setup.py b/setup.py index 63ba3b2b9..407e7b459 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ setup( version=version, - packages=["activity_browser", "activity_browser_beta"], + packages=["activity_browser"], license=open("LICENSE.txt").read(), include_package_data=True, ) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..cb1e925da --- /dev/null +++ b/tests/README.md @@ -0,0 +1,322 @@ +# tests + +Test suite for Activity Browser. + +## Overview + +This directory contains the test suite for Activity Browser using pytest. Tests verify functionality, catch regressions, and ensure code quality across the application. + +## Test Framework + +**pytest** is used as the test runner with extensions: +- **pytest-qt** - Testing Qt applications +- **pytest-cov** - Coverage reporting +- **pytest-mock** - Mocking utilities + +## Directory Structure + +- **`actions/`** - Tests for action classes +- **`fixtures/`** - Test fixtures and mock data +- **`widgets/`** - Tests for UI widgets +- Additional test files for various modules + +## Key Files + +- **`conftest.py`** - Pytest configuration and shared fixtures +- **`test_search.py`** - Search engine tests + +## Running Tests + +### Run All Tests +```bash +pytest +``` + +### Run Specific Test File +```bash +pytest tests/test_search.py +``` + +### Run Specific Test +```bash +pytest tests/test_search.py::test_search_basic +``` + +### Run with Coverage +```bash +pytest --cov=activity_browser --cov-report=html +``` + +### Run in Parallel +```bash +pytest -n auto +``` + +## Test Categories + +### Unit Tests +Test individual functions and classes in isolation: +```python +def test_function(): + result = my_function(input_data) + assert result == expected_output +``` + +### Integration Tests +Test interaction between components: +```python +def test_database_import(): + # Test full import workflow + importer.load_file(test_file) + assert database_exists("test_db") +``` + +### UI Tests +Test Qt widgets and interactions: +```python +def test_button_click(qtbot): + widget = MyWidget() + qtbot.addWidget(widget) + qtbot.mouseClick(widget.button, Qt.LeftButton) + assert widget.clicked is True +``` + +### Action Tests +Test action classes: +```python +def test_delete_action(): + DeleteAction.run(item_key) + assert not item_exists(item_key) +``` + +## Fixtures + +Fixtures provide test data and setup (see `conftest.py` and `fixtures/`): + +### Common Fixtures +```python +@pytest.fixture +def sample_activity(): + """Provide a sample activity for testing.""" + return { + "name": "Test Activity", + "unit": "kg", + "location": "GLO" + } + +def test_with_fixture(sample_activity): + # Use fixture + assert sample_activity["unit"] == "kg" +``` + +### Brightway Fixtures +```python +@pytest.fixture +def bw_project(tmp_path): + """Create temporary Brightway project.""" + bd.projects.set_current("test_project") + yield + bd.projects.delete_project("test_project", delete_dir=True) +``` + +### Qt Fixtures +```python +@pytest.fixture +def qtbot(qtbot): + """Pytest-qt bot for widget testing.""" + return qtbot +``` + +## Writing Tests + +### Test Naming +- Test files: `test_*.py` or `*_test.py` +- Test functions: `test_*` +- Test classes: `Test*` + +### Test Structure +```python +def test_something(): + # Arrange - Set up test data + data = prepare_test_data() + + # Act - Execute the code being tested + result = function_under_test(data) + + # Assert - Verify the result + assert result == expected_value +``` + +### UI Test Example +```python +def test_widget_interaction(qtbot): + # Create widget + widget = MyWidget() + qtbot.addWidget(widget) + + # Simulate user input + qtbot.keyClicks(widget.input_field, "test text") + qtbot.mouseClick(widget.submit_button, Qt.LeftButton) + + # Verify result + assert widget.result_label.text() == "Success" +``` + +### Action Test Example +```python +def test_create_database_action(bw_project): + # Setup + db_name = "test_database" + + # Execute action + CreateDatabaseAction.run(db_name) + + # Verify + assert db_name in bd.databases +``` + +## Mocking + +Use mocks to isolate tests: + +```python +from unittest.mock import Mock, patch + +def test_with_mock(mocker): + # Mock external dependency + mock_api = mocker.patch("module.api_call") + mock_api.return_value = {"status": "success"} + + # Test code + result = my_function() + + # Verify mock was called + mock_api.assert_called_once() + assert result["status"] == "success" +``` + +## Testing Signals + +Test Qt signals and slots: + +```python +def test_signal_emission(qtbot): + widget = MyWidget() + + # Use signal spy + with qtbot.waitSignal(widget.data_changed, timeout=1000): + widget.modify_data() + + # Signal was emitted +``` + +## Testing Threads + +Test background operations: + +```python +def test_threaded_operation(qtbot): + widget = MyWidget() + + # Wait for thread to complete + with qtbot.waitSignal(widget.operation_complete, timeout=5000): + widget.start_operation() + + assert widget.result is not None +``` + +## Test Coverage + +Aim for high coverage: +- **Critical paths** - 100% coverage +- **Business logic** - >90% coverage +- **UI code** - >70% coverage +- **Utilities** - >80% coverage + +View coverage report: +```bash +pytest --cov=activity_browser --cov-report=html +open htmlcov/index.html +``` + +## Continuous Integration + +Tests run automatically on: +- Pull requests +- Commits to main branch +- Scheduled runs + +See `.github/workflows/main.yaml` for CI configuration. + +## Development Guidelines + +When writing tests: + +1. **Test behavior, not implementation** - Test what, not how +2. **One assertion per test** - Or at least one logical check +3. **Descriptive names** - Test names should explain what they test +4. **Independent tests** - Tests should not depend on each other +5. **Fast tests** - Keep tests quick (mock slow operations) +6. **Readable tests** - Tests are documentation +7. **Test edge cases** - Not just happy paths +8. **Use fixtures** - Reuse common setup +9. **Mock external dependencies** - Don't rely on network, files, etc. +10. **Clean up** - Use fixtures or teardown to clean up + +## Debugging Tests + +### Run with output +```bash +pytest -s # Show print statements +pytest -v # Verbose output +pytest -vv # Very verbose +``` + +### Run single test with debugger +```bash +pytest --pdb tests/test_file.py::test_function +``` + +### Show test durations +```bash +pytest --durations=10 # Slowest 10 tests +``` + +## Test Organization + +Group related tests: + +```python +class TestDatabaseOperations: + def test_create_database(self): + pass + + def test_delete_database(self): + pass + + def test_copy_database(self): + pass +``` + +Use parametrize for similar tests: + +```python +@pytest.mark.parametrize("input,expected", [ + (1, 2), + (2, 4), + (3, 6), +]) +def test_double(input, expected): + assert double(input) == expected +``` + +## Best Practices + +- **Test first** - Write tests before or alongside code +- **Small tests** - Each test should verify one thing +- **Clear assertions** - Make expected values obvious +- **No logic in tests** - Tests should be straightforward +- **Fail fast** - Catch issues early in the test +- **Document complex tests** - Add comments for clarity +- **Keep tests updated** - Refactor tests with code +- **Review test failures** - Don't ignore failing tests diff --git a/tests/actions/test_activity_actions.py b/tests/actions/test_activity_actions.py index ffba13834..33c57dcf6 100644 --- a/tests/actions/test_activity_actions.py +++ b/tests/actions/test_activity_actions.py @@ -3,7 +3,7 @@ from bw2data.errors import BW2Exception from qtpy import QtWidgets -from activity_browser import actions, application +from activity_browser import app, app def test_activity_delete(monkeypatch, basic_database): @@ -16,7 +16,7 @@ def test_activity_delete(monkeypatch, basic_database): process = basic_database.get("process") - actions.ActivityDelete.run([process.key]) + app.actions.ActivityDelete.run([process.key]) assert len(basic_database) == 1 # removed process and products @@ -28,7 +28,7 @@ def test_activity_duplicate(basic_database): assert len(basic_database) == 4 process = basic_database.get("process") - actions.ActivityDuplicate.run([process.key]) + app.actions.ActivityDuplicate.run([process.key]) assert len(basic_database) == 7 @@ -42,13 +42,13 @@ def test_activity_duplicate(basic_database): # assert get_activity(key) # assert key not in panel.tabs # -# actions.ActivityGraph.run([key]) +# app.actions.ActivityGraph.run([key]) # # assert key in panel.tabs # # def test_activity_new(monkeypatch, basic_database): - from activity_browser.ui.widgets.new_node_dialog import NewNodeDialog + from activity_browser.app.actions.activity.activity_new_process import NewNodeDialog monkeypatch.setattr( NewNodeDialog, "exec_", staticmethod(lambda *args, **kwargs: True) @@ -62,7 +62,7 @@ def test_activity_new(monkeypatch, basic_database): assert len(basic_database) == 4 - actions.ActivityNewProcess.run(basic_database.name) + app.actions.ActivityNewProcess.run(basic_database.name) assert len(basic_database) == 6 assert len([p for p in basic_database if p["name"] == "new_process"]) == 2 @@ -72,16 +72,16 @@ def test_activity_new(monkeypatch, basic_database): def test_process_open(basic_database): process = basic_database.get("process") - actions.ActivityOpen.run([process.key]) + app.actions.ActivityOpen.run([process.key]) - group = application.main_window.centralWidget().groups["Activity Details"] + group = app.main_window.centralWidget().groups["Activity Details"] assert "activity_details_basic_process" in [group.widget(i).objectName() for i in range(group.count())] # def test_product_open(application_instance, basic_database): # product = basic_database.get("product_1") # -# actions.ActivityOpen.run([product.key]) +# app.actions.ActivityOpen.run([product.key]) # # group = application_instance.main_window.centralWidget().groups["Activity Details"] # assert "activity_details_basic_process" in [group.widget(i).objectName() for i in range(group.count())] @@ -104,6 +104,6 @@ def test_process_open(basic_database): # assert projects.current == "default" # assert list(get_activity(key).exchanges())[1].input.key == from_key # -# actions.ActivityRelink.run([key]) +# app.actions.ActivityRelink.run([key]) # # assert list(get_activity(key).exchanges())[1].input.key == to_key diff --git a/tests/actions/test_calculation_setup_actions.py b/tests/actions/test_calculation_setup_actions.py index e8f40a946..2b2eba52c 100644 --- a/tests/actions/test_calculation_setup_actions.py +++ b/tests/actions/test_calculation_setup_actions.py @@ -1,9 +1,7 @@ -import pytest import bw2data as bd -from bw2data.errors import BW2Exception from qtpy import QtWidgets -from activity_browser import actions +from activity_browser import app @@ -20,7 +18,7 @@ def test_cs_delete(monkeypatch, basic_database): assert cs_name in bd.calculation_setups - actions.CSDelete.run(cs_name) + app.actions.CSDelete.run(cs_name) assert cs_name not in bd.calculation_setups @@ -38,7 +36,7 @@ def test_cs_duplicate(monkeypatch, basic_database): assert cs_name in bd.calculation_setups assert duplicated not in bd.calculation_setups - actions.CSDuplicate.run(cs_name) + app.actions.CSDuplicate.run(cs_name) assert cs_name in bd.calculation_setups assert duplicated in bd.calculation_setups @@ -55,7 +53,7 @@ def test_cs_new(monkeypatch, basic_database): assert new_cs not in bd.calculation_setups - actions.CSNew.run() + app.actions.CSNew.run() assert new_cs in bd.calculation_setups @@ -73,7 +71,7 @@ def test_cs_rename(monkeypatch, basic_database): assert cs_name in bd.calculation_setups assert renamed_cs not in bd.calculation_setups - actions.CSRename.run(cs_name) + app.actions.CSRename.run(cs_name) assert cs_name not in bd.calculation_setups assert renamed_cs in bd.calculation_setups diff --git a/tests/actions/test_database_actions.py b/tests/actions/test_database_actions.py index 5914362ac..5882d4178 100644 --- a/tests/actions/test_database_actions.py +++ b/tests/actions/test_database_actions.py @@ -1,7 +1,7 @@ import bw2data as bd from qtpy import QtWidgets -from activity_browser import actions, application +from activity_browser import app, app def test_database_delete(monkeypatch, basic_database): @@ -11,13 +11,13 @@ def test_database_delete(monkeypatch, basic_database): staticmethod(lambda *args, **kwargs: QtWidgets.QMessageBox.Yes), ) - actions.DatabaseDelete.run([basic_database.name]) + app.actions.DatabaseDelete.run([basic_database.name]) assert basic_database.name not in bd.databases def test_database_duplicate(monkeypatch, qtbot, basic_database): - from activity_browser.actions.database.database_duplicate import NewDatabaseDialog, DuplicateDatabaseDialog + from activity_browser.app.actions.database.database_duplicate import NewDatabaseDialog, DuplicateDatabaseDialog dup_db = "db_that_is_duplicated" @@ -29,9 +29,9 @@ def test_database_duplicate(monkeypatch, qtbot, basic_database): assert dup_db not in bd.databases - actions.DatabaseDuplicate.run(basic_database.name) + app.actions.DatabaseDuplicate.run(basic_database.name) - dialog = application.main_window.findChild(DuplicateDatabaseDialog) + dialog = app.main_window.findChild(DuplicateDatabaseDialog) with qtbot.waitSignal(dialog.dup_thread.finished, timeout=60 * 1000): pass @@ -41,7 +41,7 @@ def test_database_duplicate(monkeypatch, qtbot, basic_database): def test_database_export_excel(monkeypatch, qtbot, basic_database, tmp_path): """Test exporting a database to Excel format.""" - from activity_browser.actions.database.database_export_excel import ExportExcelSetup + from activity_browser.app.actions.database.database_export_excel import ExportExcelSetup # Mock the file dialog to return a path test_path = str(tmp_path / "test_export.xlsx") @@ -52,10 +52,10 @@ def test_database_export_excel(monkeypatch, qtbot, basic_database, tmp_path): ) # Call the action - actions.DatabaseExportExcel.run([basic_database.name]) + app.actions.DatabaseExportExcel.run([basic_database.name]) # Find the wizard dialog and wait for the export thread to finish - wizard = application.main_window.findChild(ExportExcelSetup) + wizard = app.main_window.findChild(ExportExcelSetup) assert wizard is not None # Wait for the export thread to finish @@ -69,7 +69,7 @@ def test_database_export_excel(monkeypatch, qtbot, basic_database, tmp_path): def test_database_export_bw2package(monkeypatch, qtbot, basic_database, tmp_path): """Test exporting a database to BW2Package format.""" - from activity_browser.actions.database.database_export_bw2package import ExportBW2PackageSetup + from activity_browser.app.actions.database.database_export_bw2package import ExportBW2PackageSetup # Mock the file dialog to return a path test_path = str(tmp_path / "test_export.bw2package") @@ -80,10 +80,10 @@ def test_database_export_bw2package(monkeypatch, qtbot, basic_database, tmp_path ) # Call the action - actions.DatabaseExportBW2Package.run([basic_database.name]) + app.actions.DatabaseExportBW2Package.run([basic_database.name]) # Find the wizard dialog and wait for the export thread to finish - wizard = application.main_window.findChild(ExportBW2PackageSetup) + wizard = app.main_window.findChild(ExportBW2PackageSetup) assert wizard is not None # Wait for the export thread to finish @@ -96,7 +96,7 @@ def test_database_export_bw2package(monkeypatch, qtbot, basic_database, tmp_path def test_database_new(monkeypatch, basic_database): - from activity_browser.actions.database.database_new import NewDatabaseDialog + from activity_browser.app.actions.database.database_new import NewDatabaseDialog new_db = "db_that_is_new" @@ -112,20 +112,20 @@ def test_database_new(monkeypatch, basic_database): assert new_db not in bd.databases - actions.DatabaseNew.run() + app.actions.DatabaseNew.run() assert new_db in bd.databases db_number = len(bd.databases) - actions.DatabaseNew.run() + app.actions.DatabaseNew.run() assert db_number == len(bd.databases) def test_database_delete_multiple(monkeypatch, basic_database): """Test that multiple databases can be deleted at once.""" - from activity_browser.actions.database.database_new import NewDatabaseDialog + from activity_browser.app.actions.database.database_new import NewDatabaseDialog # Create two additional databases db2 = "test_db_2" @@ -140,7 +140,7 @@ def test_database_delete_multiple(monkeypatch, basic_database): monkeypatch.setattr( QtWidgets.QMessageBox, "information", staticmethod(lambda *args, **kwargs: True) ) - actions.DatabaseNew.run() + app.actions.DatabaseNew.run() assert db2 in bd.databases assert db3 in bd.databases @@ -153,7 +153,7 @@ def test_database_delete_multiple(monkeypatch, basic_database): ) # Delete both databases at once - actions.DatabaseDelete.run([db2, db3]) + app.actions.DatabaseDelete.run([db2, db3]) assert db2 not in bd.databases assert db3 not in bd.databases @@ -180,7 +180,7 @@ def test_database_delete_multiple(monkeypatch, basic_database): # assert from_db in Database(db).find_dependents() # assert to_db not in Database(db).find_dependents() # -# actions.DatabaseRelink.run(db) +# app.actions.DatabaseRelink.run(db) # # assert db in databases # assert from_db in databases diff --git a/tests/actions/test_exchange_actions.py b/tests/actions/test_exchange_actions.py index 632fff067..341b0ae17 100644 --- a/tests/actions/test_exchange_actions.py +++ b/tests/actions/test_exchange_actions.py @@ -1,8 +1,8 @@ import pytest -from stats_arrays.distributions import NoUncertainty, UndefinedUncertainty +from stats_arrays.distributions import NoUncertainty, UndefinedUncertainty, UniformUncertainty -from activity_browser import actions, application -from activity_browser.ui.wizards import UncertaintyWizard +from activity_browser import app +from activity_browser.ui.dialogs import UncertaintyDialog # def test_exchange_copy_sdf(basic_database): @@ -26,7 +26,7 @@ # assert len(exchange) == 1 # assert clipboard.text() == "FAILED" # -# actions.ExchangeCopySDF.run(exchange) +# app.actions.ExchangeCopySDF.run(exchange) # # assert clipboard.text() != "FAILED" # @@ -46,7 +46,7 @@ def test_exchange_delete(basic_database): assert len(exchange) == 1 num_exchanges = len(process.exchanges()) - actions.ExchangeDelete.run(exchange) + app.actions.ExchangeDelete.run(exchange) assert len(process.exchanges()) == num_exchanges - 1 @@ -64,7 +64,7 @@ def test_exchange_formula_remove(basic_database): assert len(exchange) == 1 assert exchange[0].as_dict().get("formula") == "5+5" - actions.ExchangeFormulaRemove.run(exchange) + app.actions.ExchangeFormulaRemove.run(exchange) with pytest.raises(KeyError): assert exchange[0].as_dict()["formula"] @@ -85,7 +85,7 @@ def test_exchange_modify(basic_database): assert len(exchange) == 1 assert exchange[0].amount == 10.0 - actions.ExchangeModify.run(exchange[0], new_data) + app.actions.ExchangeModify.run(exchange[0], new_data) assert exchange[0].amount == 200.0 @@ -102,7 +102,7 @@ def test_exchange_new(basic_database): if exchange.input == other ] - actions.ExchangeNew.run([other.key], process.key, "technosphere") + app.actions.ExchangeNew.run([other.key], process.key, "technosphere") assert ( len( @@ -116,7 +116,7 @@ def test_exchange_new(basic_database): ) -def test_exchange_uncertainty_modify(basic_database): +def test_exchange_uncertainty_modify(monkeypatch, basic_database): process = basic_database.get("process") elementary = basic_database.get("elementary") @@ -126,14 +126,35 @@ def test_exchange_uncertainty_modify(basic_database): if exchange.input == elementary ] assert len(exchange) == 1 + + # Initial state: should have NoUncertainty + assert exchange[0].uncertainty_type == NoUncertainty + + # Create mock uncertainty data to be returned by the dialog + mock_uncertainty = { + "uncertainty type": UniformUncertainty.id, + "loc": float("nan"), + "scale": float("nan"), + "shape": float("nan"), + "minimum": 5.0, + "maximum": 15.0, + "negative": False, + } + + # Monkeypatch the dialog to return our mock data + monkeypatch.setattr( + UncertaintyDialog, + "get_uncertainty_dict", + lambda *args, **kwargs: (True, mock_uncertainty), + ) - actions.ExchangeUncertaintyModify.run(exchange) - - wizard = application.main_window.findChild(UncertaintyWizard) - - assert wizard.isVisible() + app.actions.ExchangeUncertaintyModify.run(exchange) - wizard.destroy() + # Verify the exchange was updated with the new uncertainty values + assert exchange[0].uncertainty_type == UniformUncertainty + assert exchange[0]["minimum"] == 5.0 + assert exchange[0]["maximum"] == 15.0 + assert exchange[0]["negative"] == False def test_exchange_uncertainty_remove(basic_database): @@ -149,6 +170,6 @@ def test_exchange_uncertainty_remove(basic_database): assert exchange[0].uncertainty_type == NoUncertainty - actions.ExchangeUncertaintyRemove.run(exchange) + app.actions.ExchangeUncertaintyRemove.run(exchange) assert exchange[0].uncertainty_type == UndefinedUncertainty diff --git a/tests/actions/test_method_actions.py b/tests/actions/test_method_actions.py index 284d94b93..81e7921f7 100644 --- a/tests/actions/test_method_actions.py +++ b/tests/actions/test_method_actions.py @@ -5,11 +5,9 @@ from stats_arrays.distributions import ( NoUncertainty, UndefinedUncertainty, - UniformUncertainty, ) -from activity_browser import actions -from activity_browser.ui.wizards import UncertaintyWizard +from activity_browser import app def test_cf_amount_modify(basic_database): @@ -21,7 +19,7 @@ def test_cf_amount_modify(basic_database): assert len(cf) == 1 assert cf[0][1] == 1.0 or cf[0][1]["amount"] == 1.0 - actions.CFAmountModify.run(method, elementary.id, 200) + app.actions.CFAmountModify.run(method, elementary.id, 200) cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] assert cf[0][1] == 200.0 or cf[0][1]["amount"] == 200.0 @@ -36,7 +34,7 @@ def test_cf_new(basic_database): cf = [cf for cf in Method(method).load() if cf[0] == new_elementary.id] assert len(cf) == 0 - actions.CFNew.run(method, [new_elementary.key]) + app.actions.CFNew.run(method, [new_elementary.key]) cf = [cf for cf in Method(method).load() if cf[0] == new_elementary.id] @@ -57,7 +55,7 @@ def test_cf_remove(monkeypatch, basic_database): assert len(cf) == 1 - actions.CFRemove.run(method, cf) + app.actions.CFRemove.run(method, cf) cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] assert len(cf) == 0 @@ -84,14 +82,14 @@ def test_cf_remove(monkeypatch, basic_database): # assert len(cf) == 1 # assert cf[0][1].get("uncertainty type") == NoUncertainty.id # -# actions.CFUncertaintyModify.run(method, cf) +# app.actions.CFUncertaintyModify.run(method, cf) # # wizard = application_instance.main_window.findChild(UncertaintyWizard) # # assert wizard.isVisible() # # wizard.destroy() -# actions.CFUncertaintyModify.wizard_done(method, new_cf_tuple, uncertainty) +# app.actions.CFUncertaintyModify.wizard_done(method, new_cf_tuple, uncertainty) # # cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] # @@ -107,7 +105,7 @@ def test_cf_uncertainty_remove(basic_database): assert cf[0][1].get("uncertainty type") == NoUncertainty.id - actions.CFUncertaintyRemove.run(method, cf) + app.actions.CFUncertaintyRemove.run(method, cf) cf = [cf for cf in Method(method).load() if cf[0] == elementary.id] assert ( @@ -126,13 +124,13 @@ def test_method_delete(monkeypatch, basic_database): assert method in methods - actions.MethodDelete.run([method]) + app.actions.MethodDelete.run([method]) assert method not in methods def test_method_duplicate(monkeypatch, basic_database): - from activity_browser.actions.method.method_duplicate import TupleNameDialog + from activity_browser.app.actions.method.method_duplicate import TupleNameDialog method = ("basic_method",) duplicated_method = ("basic_method - Copy",) @@ -148,14 +146,14 @@ def test_method_duplicate(monkeypatch, basic_database): assert method in methods assert duplicated_method not in methods - actions.MethodDuplicate.run([method], "leaf") + app.actions.MethodDuplicate.run([method], "leaf") assert method in methods assert duplicated_method in methods def test_method_new(monkeypatch, basic_database): - from activity_browser.ui.widgets import ABListEditDialog + from activity_browser.ui.dialogs import ABListEditDialog new_method = ("New Test Method", "Test Category") @@ -174,7 +172,7 @@ def test_method_new(monkeypatch, basic_database): assert new_method not in methods - actions.MethodNew.run() + app.actions.MethodNew.run() assert new_method in methods @@ -204,7 +202,7 @@ def test_calculation_setups_updated_on_method_delete(monkeypatch, basic_database staticmethod(lambda *args, **kwargs: QtWidgets.QMessageBox.Yes), ) - actions.MethodDelete.run([method]) + app.actions.MethodDelete.run([method]) # method removed assert method not in bw_methods @@ -215,7 +213,7 @@ def test_calculation_setups_updated_on_method_delete(monkeypatch, basic_database def test_calculation_setups_updated_on_method_rename(monkeypatch, basic_database): # prepare rename dialog to accept and return new name - from activity_browser.ui.widgets import ABListEditDialog + from activity_browser.ui.dialogs import ABListEditDialog import bw2data as bd old = ("basic_method",) @@ -238,7 +236,7 @@ def test_calculation_setups_updated_on_method_rename(monkeypatch, basic_database staticmethod(lambda *args, **kwargs: new), ) - actions.MethodRename.run(old) + app.actions.MethodRename.run(old) # setups reference the new method name cs = bd.calculation_setups["basic_calculation_setup"] diff --git a/tests/conftest.py b/tests/conftest.py index f2e36a1e0..7939d4477 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,19 @@ from copy import deepcopy +from importlib import reload +from loguru import logger +import pandas as pd import pytest +import os import bw2data as bd +from PySide6 import QtCore + import bw_functional as bf from bw2data.tests import bw2test -from activity_browser import application -from activity_browser.ui.widgets import MainWindow, CentralTabWidget -from activity_browser.layouts import pages +os.environ["AB_SKIP_SETTINGS_ON_STARTUP"] = "1" +os.environ["AB_NO_SEARCHER"] = "1" @pytest.fixture @@ -21,32 +26,42 @@ def no_exception_dialogs(monkeypatch): # No need to undo the monkeypatch, pytest does it automatically -@pytest.fixture() +@pytest.fixture def main_window(qtbot, monkeypatch, no_exception_dialogs): """Return the main window of the application instance.""" - main_window = MainWindow() - central_widget = CentralTabWidget(main_window) + from activity_browser import app + from activity_browser.bwutils.metadata import metadata - qtbot.addWidget(main_window) - setattr(application, "main_window", main_window) + # Reload modules to ensure a clean state for each test + reload(metadata) + reload(app.main) + reload(app) + metadata.dataframe = pd.DataFrame() - central_widget.addTab(pages.WelcomePage(), "Welcome") - central_widget.addTab(pages.ParametersPage(), "Parameters") - - main_window.setCentralWidget(central_widget) - main_window.show() + app.main_window.show() yield main_window - # main_window.close() - main_window.deleteLater() + app.main_window.deleteLater() + qtbot.wait(10) @pytest.fixture @bw2test -def basic_database(main_window): +def basic_database(qapp, main_window): + import time + from activity_browser.app import metadata from fixtures.basic import DATABASE, METHOD, CALCULATION_SETUP + qapp.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + + i = 0 + while metadata.loader.secondary_status != "done" and i < 60: + logger.warning("Waiting for project load to finish") + time.sleep(1) + qapp.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + i += 1 + db = bf.FunctionalSQLiteDatabase("basic") db.write(deepcopy(DATABASE), process=False) db.metadata["dirty"] = True @@ -59,5 +74,15 @@ def basic_database(main_window): bd.calculation_setups["basic_calculation_setup"] = CALCULATION_SETUP bd.calculation_setups.flush() - return db + i = 0 + while metadata.loader.secondary_status != "done" and i < 60: + logger.warning("Waiting for database load to finish...") + time.sleep(1) + qapp.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + i += 1 + + if i >= 60: + raise TimeoutError("Metadata loader did not finish in time.") + + yield db diff --git a/tests/test_mds_cross_database.py b/tests/test_mds_cross_database.py new file mode 100644 index 000000000..b183759cd --- /dev/null +++ b/tests/test_mds_cross_database.py @@ -0,0 +1,119 @@ +"""Tests for MDSSearcher cross-database functionality.""" +import pytest +import pandas as pd +from activity_browser.bwutils.metadata.searcher import MDSSearcher +from activity_browser.bwutils.metadata.metadata import MetaDataStore + + +@pytest.fixture +def multi_db_mds(): + """Create a MetaDataStore with multiple databases.""" + test_data = pd.DataFrame([ + [1, "db1", "coal production", "coal", "process", "", "", "", ""], + [2, "db1", "coal mining", "coal", "process", "", "", "", ""], + [3, "db1", "steel production", "steel", "process", "", "", "", ""], + [4, "db2", "coal transport", "transport", "process", "", "", "", ""], + [5, "db2", "electricity from coal", "electricity", "process", "", "", "", ""], + [6, "db3", "coal combustion", "heat", "process", "", "", "", ""], + [7, "db3", "gas production", "gas", "process", "", "", "", ""], + ], columns=["id", "database", "name", "reference product", "type", "location", "unit", "comment", "tags"]) + + mds = MetaDataStore() + mds.dataframe = test_data + return mds + + +def test_search_single_database(multi_db_mds): + """Test searching within a single database.""" + searcher = MDSSearcher(multi_db_mds) + + # Search for "coal" in db1 + results = searcher.search("coal", database="db1") + assert len(results) == 2 + assert set(results) == {1, 2} + + # Search for "coal" in db2 + results = searcher.search("coal", database="db2") + assert len(results) == 2 + assert set(results) == {4, 5} + + # Search for "coal" in db3 + results = searcher.search("coal", database="db3") + assert len(results) == 1 + assert set(results) == {6} + + +def test_search_all_databases(multi_db_mds): + """Test searching across all databases when database=None.""" + searcher = MDSSearcher(multi_db_mds) + + # Search for "coal" across all databases + results = searcher.search("coal", database=None) + assert len(results) == 5 + assert set(results) == {1, 2, 4, 5, 6} + + # Search for "production" across all databases + results = searcher.search("production", database=None) + assert len(results) == 3 + assert set(results) == {1, 3, 7} + + +def test_fuzzy_search_all_databases(multi_db_mds): + """Test fuzzy search across all databases.""" + searcher = MDSSearcher(multi_db_mds) + + # Fuzzy search for "coal" across all databases + results = searcher.fuzzy_search("coal", database=None) + assert len(results) >= 5 + + # Fuzzy search for "production" across all databases + results = searcher.fuzzy_search("production", database=None) + assert len(results) >= 3 + + +def test_search_cache_separation(multi_db_mds): + """Test that search cache properly separates single-db and all-db searches.""" + searcher = MDSSearcher(multi_db_mds) + + # Do searches to populate cache + results_db1 = searcher.search("coal", database="db1") + results_all = searcher.search("coal", database=None) + + # Verify results are different + assert len(results_db1) == 2 + assert len(results_all) == 5 + assert set(results_db1).issubset(set(results_all)) + + # Search again to use cached results + results_3ached = searcher.search("coal", database="db1") + results_all_cached = searcher.search("coal", database=None) + + # Verify cached results match original + assert results_db1 == results_3ached + assert results_all == results_all_cached + + +def test_auto_complete_all_databases(multi_db_mds): + """Test autocomplete across all databases.""" + searcher = MDSSearcher(multi_db_mds) + + # Autocomplete for "coa" across all databases + completions = searcher.auto_complete("coa", database=None) + assert "coal" in completions + + # Autocomplete for "prod" in specific database + completions_db1 = searcher.auto_complete("prod", database="db1") + assert "production" in completions_db1 + + # Autocomplete for "prod" across all databases + completions_all = searcher.auto_complete("prod", database=None) + assert "production" in completions_all + + +def test_empty_search_all_databases(multi_db_mds): + """Test empty search returns all items when database=None.""" + searcher = MDSSearcher(multi_db_mds) + + results = searcher.search("", database=None) + assert len(results) == 7 # All items in all databases + diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 000000000..58e344037 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,239 @@ +import pytest +import pandas as pd +from activity_browser.bwutils.searchengine import SearchEngine + + +def data_for_test(): + return pd.DataFrame([ + ["a", "coal production", "coal"], + ["b", "coal production", "something"], + ["c", "coal production", "coat"], + ["d", "coal hello production", "something"], + ["e", "dont zzfind me", "hello world"], + ["f", "coat", "zzanother word"], + ["g", "coalispartofthisword", "things"], + ["h", "coal", "coal"], + ], + columns = ["id", "col1", "col2"]) + + +# test standard init +def test_search_init(): + """Do initialization tests.""" + df = data_for_test() + + # init search class with non-existent identifier col and fail + with pytest.raises(Exception): + _ = SearchEngine(df, identifier_name="non_existent_col_name") + # init search class with non-unique identifiers and fail + df2 = df.copy() + df2.iloc[0, 0] = "b" + with pytest.raises(Exception): + _ = SearchEngine(df2, identifier_name="id") + # init search class correctly + se = SearchEngine(df, identifier_name="id") + + +# test internals +def test_reverse_dict(): + """Do test to reverse the special Counter dict.""" + df = data_for_test() + se = SearchEngine(df, identifier_name="id") + + # reverse once and verify + w2i = se.reverse_dict_many_to_one(se.identifier_to_word) + assert w2i == se.word_to_identifier + + # reverse again and verify is same as original + i2w = se.reverse_dict_many_to_one(w2i) + assert i2w == se.identifier_to_word + + +def test_string_distance(): + """Do tests specifically for string distance function.""" + df = data_for_test() + se = SearchEngine(df, identifier_name="id") + + # same word + assert se.osa_distance("coal", "coal") == 0 + # empty string is length of other word + assert se.osa_distance("coal", "") == 4 + + # insert + assert se.osa_distance("coal", "coa") == 1 + # delete + assert se.osa_distance("coal", "coall") == 1 + # substitute + assert se.osa_distance("coal", "coat") == 1 + # transpose + assert se.osa_distance("coal", "cola") == 1 + + # longer edit distance + assert se.osa_distance("coal", "chocolate") == 6 + # reverse order gives same result + assert se.osa_distance("coal", "chocolate") == se.osa_distance("chocolate", "coal") + # cutoff + assert se.osa_distance("coal", "chocolate", cutoff=5, cutoff_return=1000) == 1000 + assert se.osa_distance("coal", "chocolate", cutoff=6, cutoff_return=1000) == 1000 + assert se.osa_distance("coal", "chocolate", cutoff=7, cutoff_return=1000) == 6 + # length cutoff + assert se.osa_distance("coal", "coallongword") == 8 + assert se.osa_distance("coal", "coallongword", cutoff=5, cutoff_return=1000) == 1000 + + # two entirely different words (test of early stopping) + assert se.osa_distance("brown", "jumped") == 6 + assert se.osa_distance("brown", "jumped", cutoff=6, cutoff_return=1000) == 1000 + assert se.osa_distance("brown", "jumped", cutoff=7, cutoff_return=1000) == 6 + + +# test functionality +def test_in_index(): + """Do checks for checking if word is in the index.""" + df = data_for_test() + se = SearchEngine(df, identifier_name="id") + + # use string with space + with pytest.raises(Exception): + se.word_in_index("coal and space") + + assert se.word_in_index("coal") + assert not se.word_in_index("coa") + + +def test_spellcheck(): + """Do checks spell checking.""" + df = data_for_test() + se = SearchEngine(df, identifier_name="id") + + checked = se.spell_check("coa productions something flintstones") + # coal HAS to be first, it is found more often in the data + assert checked["coa"] == ["coal", "coat"] + # find production + assert checked["productions"] == ["production"] + # should be empty as there is no alternative (but this word occurs) + assert checked["something"] == [] + # should be empty as there is no alternative (does not exist) + assert checked["flintstones"] == [] + + +def test_search_base(): + """Do checks for correct search ranking.""" + + df = data_for_test() + + # init search class and two searches + se = SearchEngine(df, identifier_name="id") + # do search on specific term + assert se.search("coal") == ["a", "h", "c", "b", "d", "g", "f"] + # do search on other term + assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] + # do search on typo + assert se.search("cola") == ["a", "c", "h", "b", "d", "f", "g"] + # do search on longer typo + assert se.search("cola production") == ["c", "a", "b", "d", "h", "f", "g"] + # do search on something we will definitely not find + assert se.search("dontFindThis") == [] + + # init search class with 1 col searchable + se = SearchEngine(df, identifier_name="id", searchable_columns=["col2"]) + assert se.search("coal") == ["a", "h", "c"] + + +def test_search_add_identifier(): + """Do tests for adding identifier.""" + df = data_for_test() + + # create base item to add + new_base_item = pd.DataFrame([ + ["i", "coal production", "coal production"], + ], + columns=["id", "col1", "col2"]) + + # use existing identifier and fail + se = SearchEngine(df, identifier_name="id") + wrong_id = new_base_item.copy() + wrong_id.iloc[0, 0] = "a" + with pytest.raises(Exception): + se.add_identifier(wrong_id) + + # add data without identifier column + se = SearchEngine(df, identifier_name="id") + no_id = new_base_item.copy() + del no_id["id"] + with pytest.raises(Exception): + se.add_identifier(no_id) + + # use column more (and find data in new col) + se = SearchEngine(df, identifier_name="id") + col_more = new_base_item.copy() + col_more["col3"] = ["potatoes"] + se.add_identifier(col_more) + assert se.search("potatoes") == ["i"] + + # use column less (should be filled with empty string) + se = SearchEngine(df, identifier_name="id") + col_less = new_base_item.copy() + del col_less["col2"] + se.add_identifier(col_less) + assert se.df.loc["i", "col2"] == "" + + # do search, add item and verify results are different + se = SearchEngine(df, identifier_name="id") + assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] + se.add_identifier(new_base_item) + assert se.search("coal production") == ["i", "a", "c", "b", "d", "h", "f", "g"] + + +def test_search_remove_identifier(caplog): + """Do tests for removing identifier.""" + caplog.set_level("WARNING") + df = data_for_test() + + # do search, remove item and verify results are different + se = SearchEngine(df, identifier_name="id") + assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] + se.remove_identifier(identifier="a") + assert se.search("coal production") == ["c", "b", "d", "h", "f", "g"] + + # now search on something only in a column we later remove + assert se.search("find") == ["e"] + se.remove_identifier(identifier="e") + assert se.search("find") == [] + + +def test_search_change_identifier(): + """Do tests for changing identifier.""" + df = data_for_test() + + # create base item to add + edit_data = pd.DataFrame([ + ["a", "cant find me anymore", "something different"], + ], + columns=["id", "col1", "col2"]) + + # use non-existent identifier and fail + se = SearchEngine(df, identifier_name="id") + missing_id = edit_data.copy() + missing_id["id"] = ["i"] + with pytest.raises(Exception): + se.change_identifier(identifier="i", data=missing_id) + + # use mismatched identifier and fail + se = SearchEngine(df, identifier_name="id") + wrong_id = edit_data.copy() + wrong_id["id"] = ["i"] + with pytest.raises(Exception): + se.change_identifier(identifier="a", data=wrong_id) + + # do search, change item and verify results are different + se = SearchEngine(df, identifier_name="id") + assert se.search("coal production") == ["a", "c", "b", "d", "h", "f", "g"] + se.change_identifier(identifier="a", data=edit_data) + assert se.search("coal production") == ["c", "b", "d", "h", "f", "g"] + # now change the same item partially and verify results are different + new_edit_data = pd.DataFrame([ + ["a", "coal"], + ], + columns=["id", "col1"]) + se.change_identifier(identifier="a", data=new_edit_data) + assert se.search("coal production") == ["c", "b", "d", "h", "a", "f", "g"]

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 NNnY(um8@y-{{g|M(G&mx diff --git a/activity_browser/docs/wiki/assets/brightway_org-scheme.png b/activity_browser/docs/wiki/assets/brightway_org-scheme.png deleted file mode 100644 index 04f79fde6651719bf76bc42ef2b3e09b9941b44d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 249518 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�ST9(PjE@y6w)TCZ2Q+ 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