From bfde9a5b5c06f85999f88ae3c23a7488cf992170 Mon Sep 17 00:00:00 2001 From: Aliaksandr Yakutovich Date: Thu, 22 Jul 2021 17:29:10 +0200 Subject: [PATCH 1/3] Add OpenAiidaNodeInAppWidget (#218). The widget works as a middle man. It receives an AiiDA node and transfers it to an app of user's choice. --- aiidalab_widgets_base/__init__.py | 3 +- aiidalab_widgets_base/nodes.py | 124 ++++++++++++++++++++++++++++++ setup.cfg | 1 + 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/aiidalab_widgets_base/__init__.py b/aiidalab_widgets_base/__init__.py index b4c13beea..1187d5eb9 100644 --- a/aiidalab_widgets_base/__init__.py +++ b/aiidalab_widgets_base/__init__.py @@ -12,7 +12,7 @@ OptimadeQueryWidget, ) from .export import ExportButtonWidget -from .nodes import NodesTreeWidget +from .nodes import NodesTreeWidget, OpenAiidaNodeInAppWidget from .process import ( ProcessCallStackWidget, ProcessFollowerWidget, @@ -50,6 +50,7 @@ "ExportButtonWidget", "MultiStructureUploadWidget", "NodesTreeWidget", + "OpenAiidaNodeInAppWidget", "OptimadeQueryWidget", "ProcessCallStackWidget", "ProcessFollowerWidget", diff --git a/aiidalab_widgets_base/nodes.py b/aiidalab_widgets_base/nodes.py index e3c128f81..2f93be9ef 100644 --- a/aiidalab_widgets_base/nodes.py +++ b/aiidalab_widgets_base/nodes.py @@ -11,10 +11,60 @@ WorkChainNode, load_node, ) +from aiidalab.app import _AiidaLabApp from IPython.display import clear_output, display from ipytree import Node as TreeNode from ipytree import Tree +CALCULATION_TYPES = [ + ( + "geo_opt", + "Geometry Optimization", + "Geometry Optimization - typically this is the first step needed to find optimal positions of atoms in the unit cell.", + ), + ( + "geo_analysis", + "Geometry analysis", + "Geometry analysis - calculate parameters describing the geometry of a material.", + ), + ( + "isotherm", + "Isotherm", + "Isotherm - compute adsorption isotherm of a small molecules in the selected material.", + ), +] + +SELECTED_APPS = [ + { + "name": "quantum-espresso", + "calculation_type": "geo_opt", + "notebook": "qe.ipynb", + "parameter_name": "structure_uuid", + "description": "Optimize atomic positions and/or unit cell employing Quantum ESPRESSO. Quantum ESPRESSO is preferable for small structures with no cell dimensions larger than 15 Å. Additionally, you can choose to compute electronic properties of the material such as band structure and density of states.", + }, + { + "name": "aiidalab-lsmo", + "calculation_type": "geo_opt", + "notebook": "multistage_geo_opt_ddec.ipynb", + "parameter_name": "structure_uuid", + "description": "Optimize atomic positions and unit cell with CP2K. CP2K is very efficient for large (any cell dimension is larger than 15 Å) and/or porous structures. Additionally, you can choose to assign point charges to the atoms using DDEC.", + }, + { + "name": "aiidalab-lsmo", + "calculation_type": "geo_analysis", + "notebook": "pore_analysis.ipynb", + "parameter_name": "structure_uuid", + "description": "Calculate descriptors for the pore geometry using the Zeo++.", + }, + { + "name": "aiidalab-lsmo", + "calculation_type": "isotherm", + "notebook": "compute_isotherm.ipynb", + "parameter_name": "structure_uuid", + "description": "Compute adsorption isotherm of the selected material using the RASPA code. Typically, one needs to optimize geometry and compute the charges of material before computing the isotherm. However, if this is already done, you can go for it.", + }, +] + class AiidaNodeTreeNode(TreeNode): def __init__(self, pk, name, **kwargs): @@ -210,3 +260,77 @@ def find_node(self, pk): if getattr(node, "pk", None) == pk: return node raise KeyError(pk) + + +class _AppIcon: + def __init__(self, app, path_to_root, node): + + name = app["name"] + app_object = _AiidaLabApp.from_id(name) + self.logo = app_object.logo + if app_object.is_installed(): + self.link = f"{path_to_root}{app['name']}/{app['notebook']}?{app['parameter_name']}={node.uuid}" + else: + self.link = f"{path_to_root}home/single_app.ipynb?app={app['name']}" + self.description = app["description"] + + def to_html_string(self): + return f""" + + + + + +

{self.description}

+ """ + + +class OpenAiidaNodeInAppWidget(ipw.VBox): + + node = traitlets.Instance(Node, allow_none=True) + + def __init__(self, path_to_root="../", **kwargs): + self.path_to_root = path_to_root + self.tab = ipw.Tab(style={"description_width": "initial"}) + self.tab_selection = ipw.RadioButtons( + options=[], + description="", + disabled=False, + style={"description_width": "initial"}, + layout=ipw.Layout(width="auto"), + ) + spacer = ipw.HTML("""

""") + super().__init__(children=[self.tab_selection, spacer, self.tab], **kwargs) + + @traitlets.observe("node") + def _observe_node(self, change): + if change["new"]: + self.tab.children = [ + self.get_tab_content(apps_type=calctype[0]) + for calctype in CALCULATION_TYPES + ] + for i, calctype in enumerate(CALCULATION_TYPES): + self.tab.set_title(i, calctype[1]) + + self.tab_selection.options = [ + (calctype[1], i) for i, calctype in enumerate(CALCULATION_TYPES) + ] + + ipw.link((self.tab, "selected_index"), (self.tab_selection, "value")) + else: + self.tab.children = [] + + def get_tab_content(self, apps_type): + + tab_content = ipw.HTML("") + + for app in SELECTED_APPS: + if app["calculation_type"] != apps_type: + continue + tab_content.value += _AppIcon( + app=app, + path_to_root=self.path_to_root, + node=self.node, + ).to_html_string() + + return tab_content diff --git a/setup.cfg b/setup.cfg index 18a8e06d5..1403b7160 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,6 +20,7 @@ classifiers = [options] packages = find: install_requires = + aiidalab>=21.07.3 aiida-core~=1.0 ansi2html~=1.6 ase<3.20 From f236dd270e0486f3b101c20585da0d1113e4435b Mon Sep 17 00:00:00 2001 From: Aliaksandr Yakutovich Date: Fri, 23 Jul 2021 15:37:49 +0200 Subject: [PATCH 2/3] Add widgets to interact with ELNs (#220). * `ElnImportWidget` to import objects from ELN to AiiDAlab. * `ElnExportWidget` to export objects from AiiDAlab to ELN. * `ElnConfigureWidget` to configure access to ELN. * `connect_to_eln` function that helps load an ELN from the database. --- aiidalab_widgets_base/__init__.py | 4 + aiidalab_widgets_base/elns.py | 285 ++++++++++++++++++++++++++++++ eln_configure.ipynb | 66 +++++++ eln_import.ipynb | 115 ++++++++++++ setup.cfg | 1 + start.py | 5 + 6 files changed, 476 insertions(+) create mode 100644 aiidalab_widgets_base/elns.py create mode 100644 eln_configure.ipynb create mode 100644 eln_import.ipynb diff --git a/aiidalab_widgets_base/__init__.py b/aiidalab_widgets_base/__init__.py index 1187d5eb9..f53e6699e 100644 --- a/aiidalab_widgets_base/__init__.py +++ b/aiidalab_widgets_base/__init__.py @@ -11,6 +11,7 @@ ComputerDatabaseWidget, OptimadeQueryWidget, ) +from .elns import ElnConfigureWidget, ElnExportWidget, ElnImportWidget from .export import ExportButtonWidget from .nodes import NodesTreeWidget, OpenAiidaNodeInAppWidget from .process import ( @@ -47,6 +48,9 @@ "CodeDropdown", "ComputerDatabaseWidget", "ComputerDropdown", + "ElnConfigureWidget", + "ElnExportWidget", + "ElnImportWidget", "ExportButtonWidget", "MultiStructureUploadWidget", "NodesTreeWidget", diff --git a/aiidalab_widgets_base/elns.py b/aiidalab_widgets_base/elns.py new file mode 100644 index 000000000..0a62563a8 --- /dev/null +++ b/aiidalab_widgets_base/elns.py @@ -0,0 +1,285 @@ +import json +from pathlib import Path + +import ipywidgets as ipw +import requests_cache +import traitlets +from aiida.orm import Node, QueryBuilder +from aiidalab_eln import get_eln_connector +from IPython.display import clear_output, display + +ELN_CONFIG = Path.home() / ".aiidalab" / "aiidalab-eln-config.json" +ELN_CONFIG.parent.mkdir( + parents=True, exist_ok=True +) # making sure that the folder exists. + + +def connect_to_eln(eln_instance=None, **kwargs): + try: + with open(ELN_CONFIG, "r") as file: + config = json.load(file) + except (FileNotFoundError, json.JSONDecodeError, KeyError): + return None + + if not eln_instance: + eln_instance = config.pop("default", None) + + if eln_instance: + if eln_instance in config: + eln_config = config[eln_instance] + eln_type = eln_config.pop("eln_type", None) + else: + eln_type = None + if not eln_type: + return None + eln = get_eln_connector(eln_type)( + eln_instance=eln_instance, **eln_config, **kwargs + ) + eln.connect() + return eln + + return None + + +class ElnImportWidget(ipw.VBox): + node = traitlets.Instance(Node, allow_none=True) + + def __init__(self, path_to_root="../", **kwargs): + # Used to output additional settings. + self._output = ipw.Output() + + # Communicate to the user if something isn't right. + error_message = ipw.HTML() + super().__init__(children=[error_message], **kwargs) + + eln = connect_to_eln(**kwargs) + + if eln is None: + url = f"{path_to_root}aiidalab-widgets-base/eln_configure.ipynb" + error_message.value = f"""Warning! The access to ELN {kwargs['eln_instance']} is not configured. Please follow the link to configure it.""" + return + + traitlets.dlink((eln, "node"), (self, "node")) + with requests_cache.disabled(): + # Since the cache is enabled in AiiDAlab, we disable it here to get correct results. + eln.import_data() + + +class ElnExportWidget(ipw.VBox): + node = traitlets.Instance(Node, allow_none=True) + + def __init__(self, path_to_root="../", **kwargs): + + self.path_to_root = path_to_root + + # Send to ELN button. + send_button = ipw.Button(description="Send to ELN") + send_button.on_click(self.send_to_eln) + + # Use non-default destination. + self.modify_settings = ipw.Checkbox( + description="Update destination.", indent=False + ) + self.modify_settings.observe(self.handle_output, "value") + + # Used to output additional settings. + self._output = ipw.Output() + + # Communicate to the user if something isn't right. + self.message = ipw.HTML() + + children = [ + ipw.HBox([send_button, self.modify_settings]), + self._output, + self.message, + ] + self.eln = connect_to_eln() + if self.eln: + traitlets.dlink((self, "node"), (self.eln, "node")) + else: + self.modify_settings.disabled = True + send_button.disabled = True + self.message.value = f"""Warning! The access to an ELN is not configured. Please follow the link to configure it.""" + + super().__init__(children=children, **kwargs) + + @traitlets.observe("node") + def _observe_node(self, _=None): + if self.node is None or self.eln is None: + return + + if "eln" in self.node.extras: + info = self.node.extras["eln"] + else: + try: + q = QueryBuilder().append( + Node, + filters={"extras": {"has_key": "eln"}}, + tag="source_node", + project="extras.eln", + ) + q.append( + Node, filters={"uuid": self.node.uuid}, with_ancestors="source_node" + ) + info = q.all(flat=True)[0] + except IndexError: + info = {} + + self.eln.set_sample_config(**info) + + def send_to_eln(self, _=None): + if self.eln and self.eln.is_connected: + self.message.value = f"\u29D7 Sending data to {self.eln.eln_instance}..." + with requests_cache.disabled(): + # Since the cache is enabled in AiiDAlab, we disable it here to get correct results. + self.eln.export_data() + self.message.value = ( + f"\u2705 The data were successfully sent to {self.eln.eln_instance}." + ) + else: + self.message.value = f"""\u274C Something isn't right! We were not able to send the data to the "{self.eln.eln_instance}" ELN instance. Please follow the link to update the ELN's configuration.""" + + def handle_output(self, _=None): + with self._output: + clear_output() + if self.modify_settings.value: + display( + ipw.HTML( + f"""Currently used ELN is: "{self.eln.eln_instance}". To change it, please follow the link.""" + ) + ) + display(self.eln.sample_config_editor()) + + +class ElnConfigureWidget(ipw.VBox): + def __init__(self, **kwargs): + self._output = ipw.Output() + self.eln = None + + self.eln_instance = ipw.Dropdown( + description="ELN:", + options=("Set up new ELN", {}), + style={"description_width": "initial"}, + ) + self.update_list_of_elns() + self.eln_instance.observe(self.display_eln_config, names=["value", "options"]) + + self.eln_types = ipw.Dropdown( + description="ELN type:", + options=["cheminfo", "openbis"], + value="cheminfo", + style={"description_width": "initial"}, + ) + self.eln_types.observe(self.display_eln_config, names=["value", "options"]) + + # Buttons. + + # Make current ELN the default. + default_button = ipw.Button(description="Set as default", button_style="info") + default_button.on_click(self.set_current_eln_as_default) + + # Save current ELN configuration. + save_config = ipw.Button( + description="Save configuration", button_style="success" + ) + save_config.on_click(self.save_eln_configuration) + + # Erase current ELN from the configuration. + erase_config = ipw.Button( + description="Erase configuration", button_style="danger" + ) + erase_config.on_click(self.erase_current_eln_from_configuration) + + # Check if connection to the current ELN can be established. + check_connection = ipw.Button( + description="Check connection", button_style="warning" + ) + check_connection.on_click(self.check_connection) + + self.my_output = ipw.HTML() + + self.display_eln_config() + + super().__init__( + children=[ + self.eln_instance, + self.eln_types, + self._output, + ipw.HBox([default_button, save_config, erase_config, check_connection]), + self.my_output, + ], + **kwargs, + ) + + def write_to_config(self, config): + with open(ELN_CONFIG, "w") as file: + json.dump(config, file, indent=4) + + def get_config(self): + try: + with open(ELN_CONFIG, "r") as file: + return json.load(file) + except (FileNotFoundError, json.JSONDecodeError, KeyError): + return {} + + def update_list_of_elns(self): + config = self.get_config() + default_eln = config.pop("default", None) + if ( + default_eln not in config + ): # Erase the default ELN if it is not present in the config + self.write_to_config(config) + default_eln = None + + self.eln_instance.options = [("Setup new ELN", {})] + [ + (k, v) for k, v in config.items() + ] + if default_eln: + self.eln_instance.label = default_eln + + def set_current_eln_as_default(self, _=None): + self.update_eln_configuration("default", self.eln_instance.label) + + def update_eln_configuration(self, eln_instance, eln_config): + config = self.get_config() + config[eln_instance] = eln_config + self.write_to_config(config) + + def erase_current_eln_from_configuration(self, _=None): + config = self.get_config() + config.pop(self.eln_instance.label, None) + self.write_to_config(config) + self.update_list_of_elns() + + def check_connection(self, _=None): + if self.eln: + err_message = self.eln.connect() + if self.eln.is_connected: + self.my_output.value = "\u2705 Connected." + return + self.my_output.value = f"\u274C Not connected. {err_message}" + + def display_eln_config(self, value=None): + """Display ELN configuration specific to the selected type of ELN.""" + eln_class = get_eln_connector(self.eln_types.value) + self.eln = eln_class( + eln_instance=self.eln_instance.label if self.eln_instance.value else "", + **self.eln_instance.value, + ) + + if self.eln_instance.value: + self.eln_types.value = self.eln.eln_type + self.eln_types.disabled = True + else: + self.eln_types.disabled = False + + with self._output: + clear_output() + display(self.eln) + + def save_eln_configuration(self, _=None): + config = self.eln.get_config() + eln_instance = config.pop("eln_instance") + if eln_instance: + self.update_eln_configuration(eln_instance, config) + self.update_list_of_elns() diff --git a/eln_configure.ipynb b/eln_configure.ipynb new file mode 100644 index 000000000..18260050c --- /dev/null +++ b/eln_configure.ipynb @@ -0,0 +1,66 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "realistic-plant", + "metadata": {}, + "outputs": [], + "source": [ + "%%javascript\n", + "IPython.OutputArea.prototype._should_scroll = function(lines) {\n", + " return false;\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "celtic-norwegian", + "metadata": {}, + "outputs": [], + "source": [ + "from aiidalab_widgets_base import ElnConfigureWidget" + ] + }, + { + "cell_type": "markdown", + "id": "understanding-authority", + "metadata": {}, + "source": [ + "# Configure ELN." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "humanitarian-editing", + "metadata": {}, + "outputs": [], + "source": [ + "display(ElnConfigureWidget())" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/eln_import.ipynb b/eln_import.ipynb new file mode 100644 index 000000000..1397cfd34 --- /dev/null +++ b/eln_import.ipynb @@ -0,0 +1,115 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "annual-documentary", + "metadata": {}, + "outputs": [], + "source": [ + "%%javascript\n", + "IPython.OutputArea.prototype._should_scroll = function(lines) {\n", + " return false;\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "celtic-norwegian", + "metadata": {}, + "outputs": [], + "source": [ + "from aiidalab_widgets_base import AiidaNodeViewWidget, OpenAiidaNodeInAppWidget, ElnImportWidget\n", + "import urllib.parse as urlparse\n", + "from aiidalab_widgets_base import viewer\n", + "from traitlets import dlink" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "through-delay", + "metadata": {}, + "outputs": [], + "source": [ + "url = urlparse.urlsplit(jupyter_notebook_url)\n", + "parsed_url = urlparse.parse_qs(url.query)\n", + "params = {key:value[0] for key, value in parsed_url.items()}\n", + "eln_widget = ElnImportWidget(**params)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "worse-elevation", + "metadata": {}, + "outputs": [], + "source": [ + "object_displayed = AiidaNodeViewWidget()\n", + "open_in_app = OpenAiidaNodeInAppWidget()\n", + "\n", + "_ = dlink((eln_widget, 'node'), (object_displayed, 'node'))\n", + "_ = dlink((eln_widget, 'node'), (open_in_app, 'node'))" + ] + }, + { + "cell_type": "markdown", + "id": "understanding-authority", + "metadata": {}, + "source": [ + "# Selected object:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "humanitarian-editing", + "metadata": {}, + "outputs": [], + "source": [ + "display(object_displayed)\n", + "display(eln_widget)" + ] + }, + { + "cell_type": "markdown", + "id": "center-pound", + "metadata": {}, + "source": [ + "# What's next?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "portable-cyprus", + "metadata": {}, + "outputs": [], + "source": [ + "display(open_in_app)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/setup.cfg b/setup.cfg index 1403b7160..44bb30f5a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,6 +21,7 @@ classifiers = packages = find: install_requires = aiidalab>=21.07.3 + aiidalab-eln~=0.1 aiida-core~=1.0 ansi2html~=1.6 ase<3.20 diff --git a/start.py b/start.py index 8c4b1dbc4..5eb4e45c9 100644 --- a/start.py +++ b/start.py @@ -9,6 +9,8 @@ Codes and computers. Processes. + + Electronic Lab Notebook. + """ From 05a3540baf631019543b9bfd74ab1c72fafd98d7 Mon Sep 17 00:00:00 2001 From: Aliaksandr Yakutovich Date: Fri, 23 Jul 2021 14:30:10 +0000 Subject: [PATCH 3/3] Prepare release 1.0.0rc1 --- aiidalab_widgets_base/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiidalab_widgets_base/__init__.py b/aiidalab_widgets_base/__init__.py index f53e6699e..58a4c6d8a 100644 --- a/aiidalab_widgets_base/__init__.py +++ b/aiidalab_widgets_base/__init__.py @@ -79,4 +79,4 @@ "viewer", ] -__version__ = "1.0.0b19" +__version__ = "1.0.0rc1"