Skip to content

Commit

Permalink
Merge pull request #1051 from marc-vdm/duplicate_to_new_location
Browse files Browse the repository at this point in the history
Add 'duplicate to new location' option for duplicating processes
  • Loading branch information
marc-vdm authored Oct 11, 2023
2 parents b219a40 + 58c7ad7 commit 39d5271
Show file tree
Hide file tree
Showing 8 changed files with 258 additions and 4 deletions.
159 changes: 157 additions & 2 deletions activity_browser/controllers/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from activity_browser.settings import project_settings
from activity_browser.signals import signals
from activity_browser.ui.wizards import UncertaintyWizard
from ..ui.widgets import ActivityLinkingDialog, ActivityLinkingResultsDialog
from ..ui.widgets import ActivityLinkingDialog, ActivityLinkingResultsDialog, LocationLinkingDialog
from .parameter import ParameterController


Expand All @@ -25,6 +25,7 @@ def __init__(self, parent=None):
signals.delete_activity.connect(self.delete_activity)
signals.delete_activities.connect(self.delete_activity)
signals.duplicate_activity.connect(self.duplicate_activity)
signals.duplicate_activity_new_loc.connect(self.duplicate_activity_new_loc)
signals.duplicate_activities.connect(self.duplicate_activity)
signals.duplicate_to_db_interface.connect(self.show_duplicate_to_db_interface)
signals.duplicate_to_db_interface_multiple.connect(self.show_duplicate_to_db_interface)
Expand Down Expand Up @@ -139,6 +140,139 @@ def duplicate_activity(self, data: Union[tuple, Iterator[tuple]]) -> None:
signals.database_changed.emit(db)
signals.databases_changed.emit()

@Slot(tuple, name="copyActivityNewLoc")
def duplicate_activity_new_loc(self, old_key: tuple) -> None:
"""Duplicates the selected activity in the same db, links to new location, with a new BW code.
This function will try and link all exchanges in the same location as the production process
to a chosen location, if none is available for the given exchange, it will try to link to
RoW and then GLO, if those don't exist, the exchange is not altered.
This def does the following:
- Read all databases in exchanges of activity into MetaDataStore
- Give user dialog to re-link location and potentially use alternatives
- Finds suitable activities with new location (and potentially alternative)
- Re-link exchanges to new (and potentially alternative) location
Parameters
----------
old_key: the key of the activity to re-link to a different location
Returns
-------
"""
act = self._retrieve_activities(old_key)[0] # we only take one activity but this function always returns list

# get list of dependent databases for activity and load to MetaDataStore
databases = []
for exch in act.technosphere():
databases.append(exch.input[0])

# 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[act.key[0]]
old_location = db.loc[db['key'] == act.key]['location'].iloc[0]

# trigger dialog with autocomplete-writeable-dropdown-list
options = (old_location, locations)
dialog = LocationLinkingDialog.relink_location(act['name'], options, self.window)
if dialog.exec_() != LocationLinkingDialog.Accepted:
# if the dialog accept button is not clicked, do nothing
return

# read the data from the dialog
for old, new in dialog.relink.items():
new_location = new
use_alternatives = dialog.use_alternatives_checkbox.isChecked()

del_exch = [] # delete these exchanges
succesful_links = {} # dict of dicts, key of new exch : {new values} <-- see 'values' below
alternatives = ['RoW', 'GLO'] # alternatives to try to match to
# 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 to re-link
for exch in act.technosphere():
db = dbs[exch.input[0]]
if db.loc[db['key'] == exch.input]['location'].iloc[0] != old_location:
continue # this exchange has a location we're not trying to re-link, continue

# 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:
continue # this activity does not exist in this database with another location (1 is self), continue

# check candidates for new_location
candidate = candidates.loc[candidates['location'] == new_location]
if len(candidate) == 0 and not use_alternatives:
continue # there is no candidate, continue
elif len(candidate) > 1:
continue # there is more than one candidate, we can't know what to use, continue
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) != 0:
break # found an alternative in with this alternative location, stop looking

# at this point, we have found 1 suitable candidate, whether that is new_location or alternative location
del_exch.append(exch)
values = {
'amount': exch.get('amount', False),
'comment': exch.get('comment', False),
'formula': exch.get('formula', False),
'uncertainty': exch.get('uncertainty', False)
}
succesful_links[candidate['key'].iloc[0]] = values

# now, create a new activity by copying the old one
db_name = act.key[0]
new_code = self.generate_copy_code(act.key)
new_act = act.copy(new_code)
# update production exchanges
for exc in new_act.production():
if exc.input.key == act.key:
exc.input = new_act
exc.save()
# update 'products'
for product in new_act.get('products', []):
if product.get('input') == act.key:
product.input = new_act.key
new_act.save()
# save the new location to the activity
self.modify_activity(new_act.key, 'location', new_location)
# delete old exchanges
signals.exchanges_deleted.emit(del_exch)
# add the new exchanges with all values carried over from last exch
signals.exchanges_add_w_values.emit(list(succesful_links.keys()), new_act.key, succesful_links)

# update the MetaDataStore and open new activity
AB_metadata.update_metadata(new_act.key)
signals.safe_open_activity_tab.emit(new_act.key)

# send signals to relevant locations
bw.databases.set_modified(db_name)
signals.database_changed.emit(db_name)
signals.databases_changed.emit()

@Slot(tuple, str, name="copyActivityToDbInterface")
@Slot(list, str, name="copyActivitiesToDbInterface")
def show_duplicate_to_db_interface(self, data: Union[tuple, Iterator[tuple]],
Expand Down Expand Up @@ -238,13 +372,29 @@ def __init__(self, parent=None):

signals.exchanges_deleted.connect(self.delete_exchanges)
signals.exchanges_add.connect(self.add_exchanges)
signals.exchanges_add_w_values.connect(self.add_exchanges)
signals.exchange_modified.connect(self.modify_exchange)
signals.exchange_uncertainty_wizard.connect(self.edit_exchange_uncertainty)
signals.exchange_uncertainty_modified.connect(self.modify_exchange_uncertainty)
signals.exchange_pedigree_modified.connect(self.modify_exchange_pedigree)

@Slot(list, tuple, name="addExchangesToKey")
def add_exchanges(self, from_keys: Iterator[tuple], to_key: tuple) -> None:
def add_exchanges(self, from_keys: Iterator[tuple], to_key: tuple, new_values: dict = {}) -> None:
"""
Add new exchanges.
Optionally add new values also.
Parameters
----------
from_keys: The activities (keys) to create exchanges from
to_key: The activity (key) to create an exchange to
new_values: Values of the exchange, dict (from_keys as keys) with field names and values for the exchange
Returns
-------
"""
activity = bw.get_activity(to_key)
for key in from_keys:
technosphere_db = bc.is_technosphere_db(key[0])
Expand All @@ -255,6 +405,11 @@ def add_exchanges(self, from_keys: Iterator[tuple], to_key: tuple) -> None:
exc['type'] = 'biosphere'
else:
exc['type'] = 'unknown'
# add optional exchange values
if new_vals := new_values.get(key, {}):
for field_name, value in new_vals.items():
if value:
exc[field_name] = value
exc.save()
bw.databases.set_modified(to_key[0])
AB_metadata.update_metadata(to_key)
Expand Down
1 change: 1 addition & 0 deletions activity_browser/layouts/tabs/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ def __init__(self, key: tuple, parent=None, read_only=True):
self.grouped_tables = [DetailsGroupBox(l, t) for l, t in self.exchange_tables]

# activity-specific data displayed and editable near the top of the tab
# this contains: activity name, location, database
self.activity_data_grid = ActivityDataGrid(read_only=self.read_only, parent=self)
self.db_read_only_changed(db_name=self.db_name, db_read_only=self.db_read_only)

Expand Down
2 changes: 2 additions & 0 deletions activity_browser/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class Signals(QObject):
new_activity = Signal(str) # Trigger dialog to create a new activity in this database | name of database
add_activity_to_history = Signal(tuple) # Add this activity to history | key of activity
duplicate_activity = Signal(tuple) # Duplicate this activity | key of activity
duplicate_activity_new_loc = Signal(tuple) # Trigger dialog to duplicate this activity to a new location | key of activity
duplicate_activities = Signal(list) # Duplicate these activities | list of activity keys
duplicate_activity_to_db = Signal(str, object) # Duplicate this activity to another database | name of target database, BW2 actiivty object
#TODO write below 2 signals to work without the str, source database is already stored in activity keys
Expand All @@ -64,6 +65,7 @@ class Signals(QObject):
# Exchanges
exchanges_deleted = Signal(list) # These exchanges should be deleted | list of exchange keys
exchanges_add = Signal(list, tuple) # Add these exchanges to this activity | list of exchange keys to be added, key of target activity
exchanges_add_w_values = Signal(list, tuple, dict) # Add these exchanges to this activity with these values| list of exchange keys to be added, key of target activity, values to add per exchange
exchange_modified = Signal(object, str, object) # This was changed about this exchange | exchange object, name of the changed field, new content of the field
# Exchange object and uncertainty dictionary
exchange_uncertainty_wizard = Signal(object) # Trigger uncertainty dialog for this exchange | exchange object
Expand Down
22 changes: 22 additions & 0 deletions activity_browser/ui/tables/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ def __init__(self, parent=None):
self.duplicate_activity_action = QtWidgets.QAction(
qicons.copy, "Duplicate activity/-ies", None
)
self.duplicate_activity_new_loc_action = QtWidgets.QAction(
qicons.copy, "Duplicate activity to new location", None
)
self.duplicate_activity_new_loc_action.setToolTip(
"Duplicate this activity to another location.\n"
"Link the exchanges to a new location if it is availabe.") # only for 1 activity
self.delete_activity_action = QtWidgets.QAction(
qicons.delete, "Delete activity/-ies", None
)
Expand All @@ -135,6 +141,16 @@ def contextMenuEvent(self, event) -> None:
if self.indexAt(event.pos()).row() == -1 and len(self.model._dataframe) != 0:
return

if len(self.selectedIndexes()) > 1:
act = 'activities'
self.duplicate_activity_new_loc_action.setEnabled(False)
else:
act = 'activity'
self.duplicate_activity_new_loc_action.setEnabled(True)

self.duplicate_activity_action.setText("Duplicate {}".format(act))
self.delete_activity_action.setText("Delete {}".format(act))

menu = QtWidgets.QMenu()
if len(self.model._dataframe) == 0:
# if the database is empty, only add the 'new' activity option and return
Expand All @@ -151,6 +167,7 @@ def contextMenuEvent(self, event) -> None:
)
menu.addAction(self.new_activity_action)
menu.addAction(self.duplicate_activity_action)
menu.addAction(self.duplicate_activity_new_loc_action)
menu.addAction(self.delete_activity_action)
menu.addAction(
qicons.edit, "Relink the activity exchanges",
Expand All @@ -176,6 +193,7 @@ def connect_signals(self):
lambda: signals.new_activity.emit(self.database_name)
)
self.duplicate_activity_action.triggered.connect(self.duplicate_activities)
self.duplicate_activity_new_loc_action.triggered.connect(self.duplicate_activity_to_new_loc)
self.delete_activity_action.triggered.connect(self.delete_activities)
self.copy_exchanges_for_SDF_action.triggered.connect(self.copy_exchanges_for_SDF)
self.doubleClicked.connect(self.open_activity_tab)
Expand Down Expand Up @@ -217,6 +235,10 @@ def delete_activities(self) -> None:
def duplicate_activities(self) -> None:
self.model.duplicate_activities(self.selectedIndexes())

@Slot(name="duplicateActivitiesToNewLocWithinDb")
def duplicate_activity_to_new_loc(self) -> None:
self.model.duplicate_activity_to_new_loc(self.selectedIndexes())

@Slot(name="duplicateActivitiesToOtherDb")
def duplicate_activities_to_db(self) -> None:
self.model.duplicate_activities_to_db(self.selectedIndexes())
Expand Down
3 changes: 3 additions & 0 deletions activity_browser/ui/tables/models/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ def duplicate_activities(self, proxies: list) -> None:
else:
signals.duplicate_activity.emit(self.get_key(proxies[0]))

def duplicate_activity_to_new_loc(self, proxies: list) -> None:
signals.duplicate_activity_new_loc.emit(self.get_key(proxies[0]))

def duplicate_activities_to_db(self, proxies: list) -> None:
if len(proxies) > 1:
keys = [self.get_key(p) for p in proxies]
Expand Down
2 changes: 1 addition & 1 deletion activity_browser/ui/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
DatabaseLinkingDialog, DefaultBiosphereDialog,
DatabaseLinkingResultsDialog, ActivityLinkingDialog,
ActivityLinkingResultsDialog, ProjectDeletionDialog,
ScenarioDatabaseDialog
ScenarioDatabaseDialog, LocationLinkingDialog
)
from .line_edit import (SignalledPlainTextEdit, SignalledComboEdit,
SignalledLineEdit)
Expand Down
2 changes: 1 addition & 1 deletion activity_browser/ui/widgets/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def duplicate_confirm_dialog(self, target_db):
""" Get user confirmation for duplication action """
title = "Duplicate activity to new database"
text = "Copy {} to {} and open as new tab?".format(
self.parent.activity.get('name', 'Error: Name of Act not found'), target_db)
self.parent.activity.get('name', 'Error: Name of activity not found'), target_db)

user_choice = QMessageBox.question(self, title, text, QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if user_choice == QMessageBox.Yes:
Expand Down
71 changes: 71 additions & 0 deletions activity_browser/ui/widgets/dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -1145,3 +1145,74 @@ def construct_dialog(cls, parent: QtWidgets.QWidget = None, options: list = None
obj.grid.addWidget(combo, i, 2, 1, 2)
obj.updateGeometry()
return obj


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_checkbox = QtWidgets.QCheckBox('Use generic alternatives (RoW, GLO) as fallback')
self.use_alternatives_checkbox.setToolTip('If the location is not found, try to match to generic locations '
'RoW or GLO (in that order).')

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_checkbox)
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[Tuple[str, List[str]]],
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[Tuple[str, List[str]]],
parent=None) -> 'LocationLinkingDialog':
label = "Relinking exchanges from activity '{}' to a new location.".format(act_name)
return cls.construct_dialog(label, options, parent)

0 comments on commit 39d5271

Please sign in to comment.